Topic 19

Upsert with INSERT ON CONFLICT

Write Patterns

"Insert this row, or update it if it is already there" is a race condition when it is written as two statements. Cartwheel meets that race four times an hour. Each warehouse posts a stock snapshot every fifteen minutes — about 4,000 rows against an inventory table of 12,000 — and the transport retries on timeout, so whole batches replay while a second worker is still loading the first copy.

INSERT … ON CONFLICT does the whole thing as one atomic statement, and the part that decides whether an upsert is correct or merely usually-correct is understanding what it conflicts on. One thing it does not do is settle Cartwheel's other open wound: the box of strawberries sold to two customers. That failure is a decision the application made between two statements — a different problem with a different fix, and Chapter 6 is where it gets one.

Why Check-Then-Insert Races

The pattern the loader grew into is a SELECT to see whether the key exists, then an INSERT or an UPDATE depending on the answer. Under Read Committed each statement takes a fresh snapshot, and neither session can see a row the other has inserted but not yet committed. Both loaders therefore get the same answer, and both act on it.

Two loaders, one key, and the gap between the two statements
-- worker A                       -- worker B
SELECT 1 FROM inventory           SELECT 1 FROM inventory
 WHERE product_id = 4471           WHERE product_id = 4471
   AND warehouse_id = 2;             AND warehouse_id = 2;
-- 0 rows                         -- 0 rows

INSERT INTO inventory …           INSERT INTO inventory …
-- commits                        -- blocks on the unique index,
                                  -- then ERROR: duplicate key

The second insert does not fail immediately. It blocks on the unique index until the first transaction ends, and only then discovers whether the outcome is a duplicate-key error or a clean insert. That error aborts the whole transaction, not just the statement, so the application either retries everything or wraps each row in a savepoint and pays for a subtransaction per row.

The Conflict Target

An upsert needs something to arbitrate the conflict, and in Postgres that something is always a unique index. ON CONFLICT (product_id, warehouse_id) infers the arbiter: every unique index on the table that contains exactly those columns, in any order, qualifies. ON CONFLICT ON CONSTRAINT inventory_pkey names one instead of inferring it. Only unique indexes and NOT DEFERRABLE constraints can arbitrate — exclusion constraints cannot, which rules out courier_shifts and its EXCLUDE rule as an upsert target. If the index is partial, its predicate has to be repeated in the conflict target for inference to match it.

The consequence people trip on is that only conflicts on the inferred arbiter are handled. A table with two unique indexes — a primary key and a natural key, say — still raises a duplicate-key error when the incoming row collides on the one that was not named, exactly as a plain insert would, and that is correct: the statement declared which conflict it knew how to resolve, and this was not it. DO NOTHING may omit the target entirely, in which case conflicts with every usable constraint are swallowed; DO UPDATE must always name one, because it has to know which existing row it is updating. One more edge deserves a note in the runbook: while CREATE INDEX CONCURRENTLY or REINDEX CONCURRENTLY is running against a unique index, INSERT … ON CONFLICT on that table can fail with a unique violation it would not otherwise have raised.

DO NOTHING, DO UPDATE, and EXCLUDED

DO NOTHING is the idempotent insert: the row goes in if it is new and is silently skipped if it is not. That is the correct shape for delivery_events, where a replayed message must not become a second row and must not rewrite the first one either. DO UPDATE merges instead, and the incoming row — the one that would have been inserted — is available under the name EXCLUDED, while the row already in the table is reachable through the table's own name or alias. Any per-row BEFORE INSERT trigger has already run by then, so its changes are visible in EXCLUDED.

Two upserts: one that merges, one that ignores
INSERT INTO inventory (product_id, warehouse_id, on_hand)
VALUES (4471, 2, 18)
    ON CONFLICT (product_id, warehouse_id) DO UPDATE
   SET on_hand = EXCLUDED.on_hand
 WHERE inventory.on_hand IS DISTINCT FROM EXCLUDED.on_hand;

-- arbitrated by a unique index on the natural key, created in the
-- same migration as this statement; id still comes from the sequence
INSERT INTO delivery_events (order_id, event_type, occurred_at, payload)
VALUES (918273645, 'picked_up', '2026-08-13 07:41:02+02',
        '{"courier_id": 88}')
    ON CONFLICT (order_id, event_type, occurred_at) DO NOTHING;

The WHERE on the DO UPDATE is not decoration. An update that sets a column to the value it already holds still writes a new row version and leaves the old one dead, exactly as any other update does. The same clause carries the conditional-merge pattern: when the incoming row has a timestamp, comparing it against the stored one makes a late-arriving replay harmless instead of destructive. Of the 4,000 rows in a warehouse snapshot, roughly 400 have actually moved; without that predicate every load writes 4,000 new row versions into a table with 12,000 live rows, four times an hour, and Chapter 7 arrives to explain why inventory occupies 900 MB.

What the arbiter index decides, row by row
The key is not presentThe row is inserted
The key is present, and the statement says DO NOTHINGSkipped, stored row untouched
The key is present, and the statement says DO UPDATEMerged from EXCLUDED
The collision is on a unique index the conflict target never namedDuplicate key error

The Sequence Numbers It Burns

A default or identity value is computed while the row is being prepared, which is before there is any way to know whether it will conflict. So an upsert that mostly conflicts still calls nextval for every attempted row, and nextval is never rolled back — that is deliberate, because a sequence that participated in transactions would serialize every writer behind it. On a bigint sequence that is a curiosity; on the 4-byte orders.id that Chapter 3 spent a whole topic widening, it would not have been. The delivery_events ingest shows it plainly: the day's replays make it attempt noticeably more inserts than the roughly 4 million rows that end up stored, and every skipped attempt still took an id with it, so the sequence runs permanently ahead of the row count.

Row Locks and Deadlocks

For a single row the promise is a strong one: ON CONFLICT DO UPDATE guarantees an atomic insert-or-update outcome, and one of the two happens even under high concurrency, provided nothing else errors. It buys that with row locks. When the update action is taken, every conflicting row is locked — including rows the WHERE clause then declines to update. Two batches that touch overlapping keys in different orders will therefore lock them in different orders, and a deadlock is the arithmetic consequence, reported to the application as a random-looking deadlock detected.

Deduplicated, sorted, and free of no-op writes
INSERT INTO inventory (product_id, warehouse_id, on_hand)
SELECT DISTINCT ON (product_id, warehouse_id)
       product_id, warehouse_id, on_hand
  FROM jsonb_to_recordset(:batch)
       AS f(product_id bigint, warehouse_id int, on_hand int,
            seen_at timestamptz)
 ORDER BY product_id, warehouse_id, seen_at DESC
    ON CONFLICT (product_id, warehouse_id) DO UPDATE
   SET on_hand = EXCLUDED.on_hand
 WHERE inventory.on_hand IS DISTINCT FROM EXCLUDED.on_hand;

Sorting by the conflict key means every worker takes its row locks in the same order, which is the ordered-locking discipline that removes the cycle — the same rule Chapter 6 states for locks in general. The DISTINCT ON, borrowed a topic early, collapses repeats within one batch and keeps the newest reading of each key. It has to: a statement may not affect the same existing row twice, so a batch carrying the same product and warehouse twice raises a cardinality violation and takes the whole load down with it.

One warehouse batch, loaded without a cardinality violation, a deadlock, or 4,000 no-op writes
Deduplicate within the batchDISTINCT ON the conflict key
Sort by the conflict keyone lock order for every worker
ON CONFLICT DO UPDATEarbitrated by the unique index
WHERE … IS DISTINCT FROM400 row versions instead of 4,000

MERGE, and Where It Fits

MERGE arrived in 15 and is the SQL-standard way to express insert, update and delete against a source relation in one statement. It reads better than an upsert for genuine reconciliation, and 17 filled in most of what was missing: a RETURNING clause, the merge_action() function that tells you which branch produced each returned row, and WHEN NOT MATCHED BY SOURCE — the branch for target rows the source never mentioned, which is a Postgres extension to the standard rather than part of it.

A full reconciliation of one warehouse, in one statement
MERGE INTO inventory i
USING (SELECT * FROM jsonb_to_recordset(:snapshot)
         AS f(product_id bigint, warehouse_id int, on_hand int)) s
   ON i.product_id = s.product_id AND i.warehouse_id = s.warehouse_id
 WHEN MATCHED AND i.on_hand IS DISTINCT FROM s.on_hand
      THEN UPDATE SET on_hand = s.on_hand
 WHEN NOT MATCHED
      THEN INSERT (product_id, warehouse_id, on_hand)
           VALUES (s.product_id, s.warehouse_id, s.on_hand)
 WHEN NOT MATCHED BY SOURCE AND i.warehouse_id = 2
      THEN DELETE
RETURNING merge_action(), i.product_id, i.on_hand;

That statement makes inventory match the snapshot for warehouse 2 exactly: rows that moved are updated, rows that are new are inserted, and rows the snapshot no longer mentions are deleted. The AND i.warehouse_id = 2 on the delete branch is the difference between reconciling one warehouse and emptying the table for all of them, and it is the clause that gets left out. What MERGE does not carry across is the concurrency guarantee: it matches, then acts, and a row inserted by another session in between produces a plain unique violation. INSERT … ON CONFLICT is the only one of the two that can turn a concurrent insert into an update.

So the division is not about elegance. ON CONFLICT owns the live path where two writers can arrive at the same key at the same instant — the feed, the event ingest, the checkout write. MERGE owns the scheduled reconciliation where you control who is running, and if it must run against live traffic it needs SERIALIZABLE and a retry loop around it. Neither of them touches the strawberries: no single statement can arbitrate a decision the application made between two statements. That one stays open until Chapter 6.

ON CONFLICT vs MERGE

INSERT … ON CONFLICT — Postgres-specific, arbitrated by a unique index, and guaranteed to end in an insert or an update even when another session is inserting the same key. This is the tool for a live upsert path.

MERGE — standard SQL, expresses insert, update and delete against a source relation in one statement, and since 17 reports what it did through merge_action(). This is the tool for scheduled reconciliation of a batch against a table.

The dividing question — can another session insert this key while the statement runs? If yes, use ON CONFLICT, or run MERGE under SERIALIZABLE with a retry and accept that you have rebuilt the guarantee by hand.

Common Mistakes
  • Writing upsert as SELECT then INSERT in application code — it passes every test and fails against the concurrency a retrying feed produces, with an error that aborts the whole transaction.
  • Assuming ON CONFLICT catches every unique violation on the table — it handles the inferred arbiter only, so a second unique index still raises a duplicate-key error.
  • Sending unsorted upsert batches from concurrent workers — the row locks are taken in different orders and the deadlock rate rises with the overlap between batches.
  • Letting the same key appear twice in one statement — a command may not affect an existing row more than once, so the load fails with a cardinality violation rather than merging twice.
  • Using DO UPDATE where DO NOTHING was meant on an append-only table — every replayed event writes a new row version of a row that never changed, and the table bloats for no gain.
  • Reaching for MERGE as a safer-looking upsert — the standard syntax hides that a concurrent insert produces a unique violation instead of an update.
Best Practices
  • Make ingestion idempotent with ON CONFLICT DO NOTHING on a natural key, so a replayed batch costs nothing and needs no coordination.
  • Name the conflict target explicitly, and create the unique index that supports it in the same migration that introduces the upsert.
  • Sort every batch by the conflict key before sending it, so concurrent workers lock rows in one consistent order.
  • Deduplicate a batch on the conflict key before it reaches the statement, since one command cannot touch the same existing row twice.
  • Add a WHERE to DO UPDATE that skips rows whose values have not changed, and measure the dead tuples it stops creating.
  • Reserve MERGE for reconciliation you schedule, and scope every WHEN NOT MATCHED BY SOURCE branch to the slice of the table the source actually covers.
Comparable toolsMySQL ON DUPLICATE KEY UPDATE, and REPLACE INTO which deletes firstOracle MERGE, the origin of the standard statementSQL Server MERGE with well-documented concurrency caveatsSQLite ON CONFLICT, borrowed directly from Postgres

Knowledge Check

Two loaders run SELECT-then-INSERT for the same key under Read Committed. What actually happens to the second one?

  • Its SELECT sees the other session's uncommitted row and skips the insert
  • Its INSERT blocks on the unique index, then fails with a duplicate key
  • Its INSERT is silently converted into an update of the existing row
  • Its transaction is rolled back with a serialization failure to be retried

An upsert arbitrated on the primary key hits a row that also violates a second unique index. What does Postgres do?

  • Handles it, because ON CONFLICT covers all unique indexes on the table
  • Raises a duplicate-key error, because only the arbiter is handled
  • Falls back to DO NOTHING for conflicts outside the named target
  • Inserts the row anyway, since the arbiter index reported no conflict

Why does an upsert that conflicts 97% of the time still consume a sequence value per row?

  • The value is allocated before the conflict is detected and is never returned
  • Each session caches a block of values and discards whatever it did not use
  • The DO UPDATE branch re-evaluates the column default for the merged row
  • The failed insert attempt is rolled back and takes the value with it

What is the concurrency difference between INSERT ... ON CONFLICT and MERGE?

  • MERGE locks the whole target table, while ON CONFLICT locks only rows
  • ON CONFLICT is atomic, while MERGE commits each branch separately
  • A concurrent insert becomes an update under ON CONFLICT, an error under MERGE
  • MERGE runs at SERIALIZABLE automatically, while ON CONFLICT does not

Why add WHERE inventory.on_hand IS DISTINCT FROM EXCLUDED.on_hand to a DO UPDATE?

  • It prevents a concurrent writer from overwriting the row being merged
  • An update that changes nothing still writes a dead row version
  • It stops the statement from taking the conflict path unnecessarily
  • It leaves the unchanged rows unlocked so other writers are not blocked

You got correct