INSERT ON CONFLICT and MERGE Plans #
An ingestion service upserts 50,000 device readings per batch with INSERT … ON CONFLICT (device_id, ts) DO UPDATE. Batches that are mostly new rows take 900 ms; batches that are mostly duplicates of previous deliveries take 6 seconds, even though “nothing changes”. A reconciliation job written with MERGE fails outright with an error about a row being modified twice. Both behaviours are visible in the plans once you know which lines to read.
The general anatomy of write plans is in data modification query plans. This page covers the two statements that combine inserting and updating.
The Planner Condition #
INSERT … ON CONFLICT attempts each insert and uses a unique index or exclusion constraint — the arbiter — to detect a conflicting existing row:
ON CONFLICT (cols)infers the arbiter from a unique index on exactly those columns (matching a partial index requires the same predicate inON CONFLICT … WHERE);ON CONFLICT ON CONSTRAINT namenames it directly.DO NOTHINGskips conflicting rows;DO UPDATE SET …updates the existing row, with the proposed row available asEXCLUDED. AWHEREonDO UPDATEcan skip updates whose values are unchanged.- Each row performs a speculative insertion: check the arbiter index, insert heap and index entries, and on a race or conflict, back out and update instead. A conflicting row therefore costs an index lookup plus a full update — new row version, index entries for non-HOT updates, WAL — even when
SETwrites identical values. - A single statement cannot affect the same target row twice; duplicate keys within one
INSERT … ON CONFLICT DO UPDATEraise an error.
MERGE (PostgreSQL 15+) is planned as a join between the source and the target on the ON condition. Each joined row is classified MATCHED or NOT MATCHED (and, from PostgreSQL 17, NOT MATCHED BY SOURCE), then routed to the first WHEN clause whose condition passes. The join is optimised like any other — hash, merge or nested loop — so source and target estimates drive the plan. A target row matched by more than one source row raises an error.
Annotated EXPLAIN Evidence #
BEGIN;
EXPLAIN (ANALYZE, WAL)
INSERT INTO readings (device_id, ts, value)
SELECT device_id, ts, value FROM incoming_batch
ON CONFLICT (device_id, ts) DO UPDATE SET value = EXCLUDED.value;
ROLLBACK;
Insert on readings (actual time=6102.4..6102.4 rows=0 loops=1)
Conflict Resolution: UPDATE
Conflict Arbiter Indexes: readings_device_ts_key
Tuples Inserted: 2104
Conflicting Tuples: 47896
WAL: records=241220 fpi=18204 bytes=31204880
-> Seq Scan on incoming_batch (actual rows=50000 loops=1)
Execution Time: 6110.8 ms
-- 47,896 of 50,000 rows already existed and were updated to (mostly) the same value
-- each conflict: arbiter lookup + new row version + index entries + WAL
With a guard that skips unchanged values:
... ON CONFLICT (device_id, ts) DO UPDATE SET value = EXCLUDED.value
WHERE readings.value IS DISTINCT FROM EXCLUDED.value;
Insert on readings (actual time=842.6..842.6 rows=0 loops=1)
Conflict Resolution: UPDATE
Conflict Arbiter Indexes: readings_device_ts_key
Conflict Filter: (readings.value IS DISTINCT FROM excluded.value)
Rows Removed by Conflict Filter: 47102
Tuples Inserted: 2104
Conflicting Tuples: 794
WAL: records=14410 fpi=2102 bytes=2804410
Execution Time: 848.3 ms
-- only 794 real changes written; WAL down by 91%
A MERGE:
Merge on accounts a (actual time=4102.6..4102.6 rows=0 loops=1)
Tuples: inserted=1204 updated=18402 deleted=312 skipped=180082
-> Hash Right Join (actual rows=200000 loops=1)
Hash Cond: (a.external_id = s.external_id)
-> Seq Scan on accounts a
-> Hash -> Seq Scan on account_feed s
-- skipped: joined rows with no WHEN clause applying (unchanged)
Step-by-Step Resolution #
-
Read the conflict ratio.
Conflicting Tuples ÷ (Tuples Inserted + Conflicting Tuples). A high ratio with identical values means most work is rewriting unchanged rows. -
Add a conflict filter so identical rows are skipped:
ON CONFLICT (device_id, ts) DO UPDATE SET value = EXCLUDED.value WHERE readings.value IS DISTINCT FROM EXCLUDED.value -
Deduplicate the source batch. Duplicate keys within one statement raise “cannot affect row a second time” for
ON CONFLICT DO UPDATEand “MERGE command cannot affect row a second time” forMERGE:SELECT DISTINCT ON (device_id, ts) device_id, ts, value FROM incoming_batch ORDER BY device_id, ts, received_at DESC; -
Confirm the arbiter index is the one you expect — especially with several unique indexes or partial unique indexes on the table.
-
For
MERGE, tune the join. Analyze the source table, make sure theONcolumns are indexed on the target when the source is small, and addANDconditions toWHEN MATCHEDclauses so unchanged rows are skipped:MERGE INTO accounts a USING account_feed s ON a.external_id = s.external_id WHEN MATCHED AND (a.name, a.plan) IS DISTINCT FROM (s.name, s.plan) THEN UPDATE SET name = s.name, plan = s.plan WHEN NOT MATCHED THEN INSERT (external_id, name, plan) VALUES (s.external_id, s.name, s.plan); -
Use
ON CONFLICTfor concurrent writers.MERGEdoes not use speculative insertion; concurrentMERGEs inserting the same new key can still fail on the unique index, whereasINSERT … ON CONFLICThandles that race.
Before and After #
-- BEFORE: unconditional DO UPDATE
Insert on readings Conflicting Tuples: 47896 WAL: bytes=31204880 Execution Time: 6110.8 ms
-- AFTER: DO UPDATE … WHERE value IS DISTINCT FROM EXCLUDED.value
Insert on readings Rows Removed by Conflict Filter: 47102 Conflicting Tuples: 794 WAL: bytes=2804410 Execution Time: 848.3 ms
Why a conflicting row is expensive #
It is tempting to think of an upsert as “check, then maybe insert”. The speculative insertion mechanism is more careful, because two sessions may try to insert the same key at the same moment. Each row first checks the arbiter index; if no conflict is visible, the executor inserts the heap tuple and index entries while marking the insertion speculative, then confirms it. If a conflicting row appeared in between, the speculative insertion is killed and the statement proceeds to the conflict action. When the conflict is visible upfront, the path is shorter: lock the existing row, evaluate the DO UPDATE filter, and write a new version. Every step touches the unique index and the heap, so a batch that is 95% conflicts does roughly as much work as a batch of 95% plain updates. Skipping unchanged rows with a conflict filter is the only way to make duplicates cheap.
For bulk loads where the incoming data is mostly new, loading into a staging table and using MERGE or an anti-join insert — INSERT … SELECT … WHERE NOT EXISTS — avoids per-row speculative insertion entirely, as long as no concurrent writer inserts the same keys. The ORM-side patterns for these statements are in ORM bulk insert and upsert plans.
Common Pitfalls #
Rewriting identical rows on conflict. Every conflict creates a new version and WAL. Diagnostic signal: high Conflicting Tuples for re-delivered data. Fix: a WHERE … IS DISTINCT FROM EXCLUDED… conflict filter.
Duplicate keys in one batch. The statement fails. Diagnostic signal: “cannot affect row a second time”. Fix: deduplicate the source first.
Partial unique index not inferred. ON CONFLICT (cols) does not match a partial index without the same WHERE. Diagnostic signal: error that no unique constraint matches. Fix: add the index predicate to ON CONFLICT … WHERE.
MERGE under concurrent inserts. Two sessions can both see NOT MATCHED. Diagnostic signal: unique violations from concurrent MERGE jobs. Fix: INSERT … ON CONFLICT for concurrent paths, or serialize the jobs.
Frequently Asked Questions #
What does Conflicting Tuples mean in EXPLAIN ANALYZE? #
It counts the rows whose insertion found an existing row through the arbiter index and were handled by the ON CONFLICT action instead. With DO UPDATE, each of those rows was updated unless a conflict filter removed it.
Is MERGE faster than INSERT ON CONFLICT? #
For synchronising large sets, MERGE can be faster because it is planned as one join rather than a per-row index check. For concurrent single-row upserts, INSERT ON CONFLICT is the safer choice because it handles races on the unique key.
Why does my upsert fail with “cannot affect row a second time”? #
The source contains the same conflict key more than once in one statement. PostgreSQL refuses to update the same row twice in a single command; deduplicate the input first.
Related #
- Data Modification Query Plans — parent guide: writes, triggers and WAL
- UPDATE and DELETE Join Plans — sibling: join-based updates
- Partial Unique Indexes for Conditional Constraints — arbiter indexes with predicates