25 UPSERT (ON CONFLICT)
“Upsert” is the operation that means “insert if it does not exist; otherwise update.” PostgreSQL spells it as INSERT ... ON CONFLICT ... DO UPDATE (or DO NOTHING). It is atomic, race-free, and one of the most-used patterns in production code. Before this clause existed, applications had to do a SELECT-then-INSERT-then-UPDATE dance that was either slow or wrong under concurrency.
What you’ll learn
INSERT ... ON CONFLICT DO NOTHINGto skip duplicatesINSERT ... ON CONFLICT (col) DO UPDATE SET ...to merge new and old values- Referencing the proposed values via the
EXCLUDEDvirtual table - The conflict target: column, constraint name, or expression
RETURNINGwithON CONFLICTand when it gives you the row
Example
The conflict target — the parenthesized clause after ON CONFLICT — tells PostgreSQL which unique constraint or index defines the conflict. (email) matches the unique key on email. You can also name a constraint: ON CONFLICT ON CONSTRAINT visitors_example_pkey DO UPDATE .... For expression indexes, repeat the expression: ON CONFLICT ((lower(email))). A partial index can also require a matching predicate after the conflict target.
EXCLUDED is the virtual table representing the row your INSERT proposed. visitors_example (or whatever the actual table is called) refers to the existing row. Combining them lets you express increments (SET visits = visitors_example.visits + 1), merges (SET name = COALESCE(EXCLUDED.name, visitors_example.name)), and so on.
RETURNING with ON CONFLICT DO UPDATE returns every affected row (inserted or updated). DO NOTHING only returns truly inserted rows — if a conflict was skipped, you get nothing back.
Run the example
To run:
Common pitfalls
ON CONFLICTrequires a matching unique constraint or index to be defined. Without one, PostgreSQL has no way to know which row would conflict and raises an error.- The conflict target must exactly match a constraint or index.
ON CONFLICT (lower(email))only works if you have a unique expression index onlower(email). DO NOTHINGis fine when you really mean “best-effort insert.” It silently swallows real bugs if you actually expected the row to be inserted.RETURNINGafterDO NOTHINGdoes not return a row for the skipped insert. If you need “the row, whether you inserted it or it already existed,” useDO UPDATEwith a no-opSETlikeSET email = EXCLUDED.emailso a row is always returned.
From PostgreSQL 15 onwards, the SQL-standard MERGE statement is also available — more flexible (multiple matched / not-matched branches) but more verbose. INSERT ... ON CONFLICT is still the right tool for simple upserts.
Run the second insert (DO NOTHING) twice — Alice’s visit count should not increase. Then run the third (DO UPDATE) twice — it should each time. Add RETURNING * to each and observe which return rows.
Downloads
SQL file: upsert.sql · All lesson files · Setup guide