- Learn
- PostgreSQL
- PostgreSQL ON CONFLICT: upsert syntax and examples
PostgreSQL ON CONFLICT: upsert syntax and examples
Upsert in PostgreSQL with INSERT ... ON CONFLICT DO UPDATE. Here is the syntax, the EXCLUDED table, and the traps.
Quick answer
Insert a row, or update it if it already exists:
Insert a row, or do nothing if it already exists:
This is PostgreSQL's upsert. There is no UPSERT keyword, and MERGE (added in PostgreSQL 15) is a different, more general tool.
Setting up
Every example below uses this table:
ON CONFLICT DO NOTHING
The simplest form. If the insert would violate a constraint, skip it:
Note the 0 0. Nothing was inserted, and the existing row was left alone:
You can omit the conflict target entirely, which catches a conflict on any constraint:
That is convenient and usually a mistake. It will silently swallow a conflict on a constraint you were not thinking about, including one added months later. Name the target unless you genuinely mean "any conflict at all".
ON CONFLICT DO UPDATE
The real upsert. When a conflict happens, update the existing row instead:
Note the rowcount is 1 even though this was an update, not an insert. PostgreSQL reports it as an INSERT because that is the statement you ran. You cannot tell insert from update by the rowcount alone. See the RETURNING section for how to find out.
The EXCLUDED table
EXCLUDED is the row you tried to insert. It is not a real table; it is a reference PostgreSQL makes available inside the DO UPDATE clause.
That gives you two sets of values to work with:
EXCLUDED.columnis the incoming valueinventory.column(the table name) is the existing value
Which means you can do arithmetic across both. To add to a running total rather than replacing it:
99 + 5. This is the pattern for counters, stock levels, and anything additive.
Qualify the column with the table name when you mean the existing value. Bare qty inside SET is ambiguous to read even where it parses, and SET qty = qty + 1 does not mean what a reader expects.
Updating a timestamp on upsert
A common shape, updating a modified time only when the row actually changes:
Conditional updates with WHERE
DO UPDATE accepts a WHERE clause, so you can decline the update:
That only overwrites when the incoming quantity is higher. When the WHERE is false, nothing happens and the rowcount is 0, exactly like DO NOTHING.
This is useful for last-write-wins guards:
The conflict target needs a unique constraint
ON CONFLICT works by detecting a unique violation, so the column you name must actually have a unique index or constraint behind it:
The fix is to add the constraint, not to change the query:
You can also name a constraint directly instead of its columns:
Naming columns is usually better, because it survives a constraint being renamed.
Composite and partial targets
For a multi-column unique constraint, list all the columns:
If the unique index is partial, you have to repeat its predicate so PostgreSQL knows which index you mean:
Omit the WHERE and you get the same "no unique or exclusion constraint matching" error, which is confusing when you can see the index right there.
RETURNING, and the trap in it
RETURNING works, and is the clean way to get the row back:
But RETURNING with DO NOTHING returns nothing when a conflict happens:
This catches people out constantly. Code that does INSERT ... ON CONFLICT DO NOTHING RETURNING id and then reads the id gets nothing back on the exact path where the row already exists, which is the path you were guarding against. If you need the id either way, use DO UPDATE with a no-op:
That always returns a row. It costs a pointless write, which is a real trade-off on a hot table, but it is predictable.
Telling insert from update
xmax is a system column that is zero for a fresh insert and non-zero for an updated row:
This works and is widely used, but it leans on an implementation detail rather than a documented contract. Fine for logging or metrics. Do not build correctness on it.
Bulk upserts
ON CONFLICT handles multi-row inserts, which is the fastest way to sync a batch:
One caveat that produces a genuinely confusing error: a single statement cannot update the same row twice.
De-duplicate your batch before sending it. The hint is unusually clear about what to do.
ON CONFLICT vs MERGE
PostgreSQL 15 added MERGE, which is the SQL-standard statement for this kind of work:
ON CONFLICT | MERGE | |
|---|---|---|
| Since | 9.5 | 15 |
| Standard SQL | No, PostgreSQL-specific | Yes |
| Handles DELETE | No | Yes |
| Concurrency-safe upsert | Yes | Not inherently |
RETURNING | Yes | Only from PG 17 |
The important row is the concurrency one. ON CONFLICT is built on unique-violation detection, so it is safe under concurrent inserts by design. MERGE matches on a join condition and can still raise a unique violation if another transaction inserts between the match and the write.
For plain upserts, use ON CONFLICT. Reach for MERGE when you need conditional deletes or genuinely portable SQL.
Common errors
ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
The columns you named have no unique index or constraint. Add one. If the index is partial, repeat its WHERE clause in the ON CONFLICT target.
ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time
Your batch contains duplicate keys. De-duplicate before inserting.
ERROR: column reference "qty" is ambiguous
Inside DO UPDATE SET, qualify the existing value with the table name (inventory.qty) and the incoming value with EXCLUDED.qty.
Quick reference
| Task | Syntax |
|---|---|
| Skip on conflict | ON CONFLICT (col) DO NOTHING |
| Overwrite on conflict | ON CONFLICT (col) DO UPDATE SET x = EXCLUDED.x |
| Add to existing value | DO UPDATE SET n = tbl.n + EXCLUDED.n |
| Update only if newer | DO UPDATE SET ... WHERE tbl.version < EXCLUDED.version |
| Target a named constraint | ON CONFLICT ON CONSTRAINT tbl_pkey DO UPDATE ... |
| Target a partial index | ON CONFLICT (col) WHERE pred DO UPDATE ... |
| Always get a row back | DO UPDATE SET col = tbl.col RETURNING * |
| Did it insert or update? | RETURNING (xmax = 0) AS was_inserted |