PostgreSQL ON CONFLICT vs MERGE for Idempotent Upserts

PostgreSQL 15 added SQL-standard MERGE, and the obvious question followed: should idempotent writes move to it from INSERT ... ON CONFLICT? For deduplication specifically the answer is usually no, and the reason is concurrency behaviour rather than syntax preference. This decision guide sits under database unique constraints & upserts.

Prerequisites. PostgreSQL 15 or later to run both forms, a unique constraint or exclusion constraint on the deduplication key, and the background from PostgreSQL unique constraints vs application-level checks.


Step 1 — The same write, both ways

-- Schema shared by both examples.
CREATE TABLE idempotency_keys (
    scoped_key  text PRIMARY KEY,
    fingerprint bytea       NOT NULL,
    state       text        NOT NULL DEFAULT 'pending',
    status_code smallint,
    response    jsonb,
    expires_at  timestamptz NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);
Round trips on the hot path Two sequences. INSERT with ON CONFLICT and RETURNING answers both the win and the replay case in one round trip, using the xmax test to distinguish them. MERGE before PostgreSQL 17 has no RETURNING, so the replay case needs a second select inside the same transaction, doubling the latency of the most common path. ON CONFLICT INSERT … RETURNING, (xmax = 0) 1 round trip, both cases MERGE (pg < 17) MERGE … SELECT to learn the outcome 2 round trips The replay path is the common path — doubling it is not a rounding error.
-- A: INSERT ... ON CONFLICT — the PostgreSQL-native form.
INSERT INTO idempotency_keys (scoped_key, fingerprint, expires_at)
VALUES ($1, $2, now() + interval '24 hours')
ON CONFLICT (scoped_key) DO NOTHING
RETURNING scoped_key;
-- Zero rows returned  → the key already existed: read it and replay.
-- One row returned    → we own it: execute the handler.
-- B: MERGE — the SQL-standard form.
MERGE INTO idempotency_keys AS t
USING (VALUES ($1::text, $2::bytea)) AS s(scoped_key, fingerprint)
   ON t.scoped_key = s.scoped_key
WHEN NOT MATCHED THEN
    INSERT (scoped_key, fingerprint, expires_at)
    VALUES (s.scoped_key, s.fingerprint, now() + interval '24 hours')
WHEN MATCHED THEN
    DO NOTHING;

They read similarly. They behave differently the moment two sessions run them at once.


Step 2 — Behaviour under concurrent inserts

Concurrency behaviour of ON CONFLICT versus MERGE Two panels. On the left, two sessions run INSERT ON CONFLICT DO NOTHING for the same key. Session one inserts and commits; session two blocks on the index, then re-reads and takes the conflict path, returning zero rows without error. On the right, two sessions run MERGE. Both evaluate the NOT MATCHED branch under read committed, session one inserts and commits, and session two's insert raises a unique violation error that the application must catch. INSERT … ON CONFLICT DO NOTHING session 1 INSERT → commit session 2 blocks on index session 1 commits, index lock released session 2 re-reads the conflicting row takes the DO NOTHING branch internally 0 rows returned, NO error application reads and replays MERGE … WHEN NOT MATCHED session 1 NOT MATCHED → insert session 2 NOT MATCHED → insert both evaluated the join before either committed session 1 commits first session 2's insert hits the unique index ERROR 23505 unique_violation application MUST catch and retry

ON CONFLICT has special machinery for this: on hitting a conflicting tuple it waits for the other transaction, then re-reads and takes the conflict action rather than failing. MERGE has no such machinery — it evaluates its join once, and under READ COMMITTED two sessions can both conclude NOT MATCHED.

That is not a bug in MERGE; the SQL standard specifies it that way, and PostgreSQL’s documentation says so explicitly. It just makes MERGE the wrong default for a hot deduplication path, where concurrent duplicates are the normal case rather than an edge case.

# MERGE therefore needs the retry loop that ON CONFLICT does not.
from psycopg.errors import UniqueViolation

def register_with_merge(conn, scoped, fingerprint, attempts=3):
    for _ in range(attempts):
        try:
            with conn.transaction():
                conn.execute(MERGE_SQL, (scoped, fingerprint))
            return
        except UniqueViolation:
            continue           # a concurrent session won; re-read and replay
    raise RuntimeError("merge contention did not resolve")

Step 3 — RETURNING support

The deduplication flow needs to know whether it won the race, which means it needs a value back.

-- ON CONFLICT: RETURNING is supported and is the natural signal.
INSERT INTO idempotency_keys (scoped_key, fingerprint, expires_at)
VALUES ($1, $2, now() + interval '24 hours')
ON CONFLICT (scoped_key) DO NOTHING
RETURNING scoped_key;              -- 1 row = we won, 0 rows = replay

-- Need the existing row on conflict too? Force an update that changes nothing:
INSERT INTO idempotency_keys (scoped_key, fingerprint, expires_at)
VALUES ($1, $2, now() + interval '24 hours')
ON CONFLICT (scoped_key) DO UPDATE
    SET scoped_key = EXCLUDED.scoped_key       -- no-op, but makes the row visible
RETURNING scoped_key, state, status_code, response, (xmax = 0) AS inserted;

(xmax = 0) is the idiomatic PostgreSQL trick for distinguishing an insert from an update in a single statement: a freshly inserted tuple has xmax of zero, an updated one does not. It saves a round trip on the replay path.

MERGE gained RETURNING only in PostgreSQL 17, so on 15 and 16 you need a follow-up SELECT inside the same transaction — another round trip on the hot path.


Step 4 — The decision

Criterion ON CONFLICT MERGE
Concurrent-insert safety Handled internally, no error Raises 23505, needs an application retry
RETURNING Since 9.5 PostgreSQL 17+ only
Distinguish insert from update (xmax = 0) in one statement Needs a separate query before 17
Multiple actions from one source No — insert or one update action Yes — insert, update and delete branches
Requires a unique constraint Yes, on the conflict target No — joins on any condition
SQL-standard portability PostgreSQL-specific Standard; also in Oracle, SQL Server, Db2
Best fit here Single-row idempotent writes on a hot path Batch reconciliation with mixed actions

Use ON CONFLICT for deduplication. It is one statement, one round trip, no error path under contention, and it tells you which branch fired. The portability argument is weak for a table that already depends on PostgreSQL-specific types and a partial index.

Use MERGE for batch reconciliation — a nightly job that inserts new ledger rows, updates changed ones and closes stale ones from a single staging query. That is what it is good at, and there the contention that makes it awkward here does not exist.


Step 5 — Verify under concurrency

import concurrent.futures as cf, psycopg, pytest

@pytest.mark.parametrize("trial", range(50))
def test_on_conflict_never_raises(trial, dsn):
    key = f"idem:v1:concurrency-{trial}"
    barrier = cf.ThreadPoolExecutor(max_workers=64)

    def attempt(_):
        with psycopg.connect(dsn) as conn, conn.cursor() as cur:
            cur.execute(
                """INSERT INTO idempotency_keys (scoped_key, fingerprint, expires_at)
                   VALUES (%s, %s, now() + interval '24 hours')
                   ON CONFLICT (scoped_key) DO NOTHING
                   RETURNING scoped_key""",
                (key, b"\x00" * 32),
            )
            return cur.rowcount

    results = list(barrier.map(attempt, range(64)))
    assert sum(results) == 1        # exactly one winner, and never an exception
# Same check from psql, 64 parallel sessions.
KEY="idem:v1:shell-probe-$RANDOM"
seq 64 | xargs -P 64 -I{} psql -qtA -c \
  "INSERT INTO idempotency_keys (scoped_key, fingerprint, expires_at)
   VALUES ('$KEY', '\\x00', now() + interval '24 hours')
   ON CONFLICT (scoped_key) DO NOTHING RETURNING 1;" | grep -c 1
# expect: 1

# The MERGE equivalent will print unique-violation errors to stderr — that is the point.

Confirm the index is actually used rather than a sequential scan, which would make the whole thing slow under load:

EXPLAIN (ANALYZE, BUFFERS)
INSERT INTO idempotency_keys (scoped_key, fingerprint, expires_at)
VALUES ('probe', '\x00', now() + interval '24 hours')
ON CONFLICT (scoped_key) DO NOTHING;
-- expect: "Conflict Resolution: NOTHING" and "Conflict Arbiter Indexes: idempotency_keys_pkey"

Failure scenarios and debugging

Failure Scenario Remediation Steps Observability Hooks
MERGE adopted on the hot path without a retry loop, and duplicate requests surface as 23505 errors to clients Switch to ON CONFLICT, or wrap the MERGE in a bounded retry that treats 23505 as “a peer won, replay”. pg_stat_database.xact_rollback rising with request rate; error-log grep for 23505 on the dedup table
ON CONFLICT DO NOTHING ... RETURNING used, and the code treats zero rows as a failure rather than a replay Branch explicitly on rowcount: one means execute, zero means read the existing row and replay. Add the test above. idempotency_registration_outcome_total{outcome}won versus replay; a zero replay rate means the branch is wrong
Conflict target omitted (ON CONFLICT DO NOTHING with no column list), silently swallowing violations on any constraint Always name the arbiter: ON CONFLICT (scoped_key). An unqualified form hides a genuine data bug on some other constraint. A schema lint rejecting ON CONFLICT DO without a target on write paths
Expression index or partial index used as the arbiter, but the statement’s predicate does not match, so PostgreSQL cannot infer it Match the index predicate exactly in the ON CONFLICT clause, or name the constraint with ON CONFLICT ON CONSTRAINT. EXPLAIN output missing a “Conflict Arbiter Indexes” line; error 42P10 at runtime
Both forms used in different services against one table, so behaviour under contention differs by caller Standardise on ON CONFLICT in a shared data-access module and remove the alternative. A repository grep in CI; idempotency_registration_outcome_total split by service showing divergent error rates
The arbiter must be inferable A partial unique index carries a predicate limiting it to unexpired rows. An ON CONFLICT clause naming only the column cannot infer that index and fails at runtime with error 42P10. Repeating the same predicate in the ON CONFLICT clause, or naming the constraint explicitly, lets PostgreSQL match it. CREATE UNIQUE INDEX … ON t (scoped_key) WHERE expires_at > now() ON CONFLICT (scoped_key) DO NOTHING 42P10 — cannot infer ON CONFLICT (scoped_key) WHERE expires_at > now() DO NOTHING matches

SRE / observability checklist

  1. idempotency_registration_outcome_total{outcome} — Counter split into won and replay. The replay share is the natural duplicate rate, typically 0.5%–2%.
  2. idempotency_registration_duration_ms — Histogram of the single statement. Alert if p99 exceeds 15 ms; contention on the arbiter index shows here first.
  3. pg_stat_user_tables.n_tup_ins versus distinct keys — A divergence means the conflict path is not firing and duplicates are being inserted under different keys.
  4. pg_locks waits on the primary key index — Gauge of blocked sessions. A rising count means concurrent duplicates are arriving faster than the winner commits.
  5. unique_violation_total — Counter parsed from the error log for the deduplication table. On ON CONFLICT this should be flat at zero; any value means a code path bypasses it.
  6. pgstatindex(...).leaf_fragmentation — Gauge on the primary key. Above 30% means the key format is fighting the B-tree; see UUIDv4 vs UUIDv7 vs ULID.