Upserts: ON CONFLICT and MERGE

SQL · Advanced · 11 min

Syncing data, counters and idempotent APIs all need 'insert or update' without race conditions.

"Insert unless it exists, otherwise update" looks like a SELECT followed by an INSERT or UPDATE. Under concurrency that is a race: two requests both see no row and both insert, and one fails or duplicates data. Do it in one atomic statement instead.

PostgreSQL's upsert is INSERT ... ON CONFLICT:

INSERT INTO customers (email, name) VALUES ('ana@example.com', 'Ana K.')
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name;
  • The conflict target (email) must match a unique index or constraint; that index is what detects the conflict.
  • EXCLUDED refers to the row you tried to insert; the plain table name refers to the existing row. That allows increments: SET qty = daily_sales.qty + EXCLUDED.qty.
  • DO UPDATE ... WHERE updates only when a condition holds, for example only if the incoming data is newer.
  • DO NOTHING silently skips conflicting rows, which makes inserts idempotent.

One multi-row INSERT cannot touch the same row twice: if two input rows share an email, PostgreSQL raises "ON CONFLICT DO UPDATE command cannot affect row a second time". De-duplicate the input first.

MERGE (SQL standard; PostgreSQL 15+, SQL Server, Oracle) is more general: it joins a source to a target and runs different actions WHEN MATCHED and WHEN NOT MATCHED, including deletes. It is ideal for batch synchronisation from a staging table. In PostgreSQL, however, MERGE is not protected against concurrent inserts the way ON CONFLICT is: two concurrent MERGEs can both decide to insert, and one then fails with a unique violation. For concurrent single-row upserts, prefer ON CONFLICT.

MySQL's equivalent is INSERT ... ON DUPLICATE KEY UPDATE; SQLite supports PostgreSQL's ON CONFLICT syntax.

Example

CREATE TABLE daily_sales (
    product_id bigint REFERENCES products (id),
    day        date,
    qty        integer NOT NULL,
    PRIMARY KEY (product_id, day)
);

-- Atomic counter: insert the first sale, increment afterwards
INSERT INTO daily_sales (product_id, day, qty) VALUES (2, '2026-03-15', 1)
ON CONFLICT (product_id, day)
DO UPDATE SET qty = daily_sales.qty + EXCLUDED.qty
RETURNING qty;          -- 1 the first time, 2 the second time, ...

-- Idempotent insert
INSERT INTO customers (name, email) VALUES ('Ana', 'ana@example.com')
ON CONFLICT (email) DO NOTHING;     -- INSERT 0 0

-- Batch sync from a staging table (PostgreSQL 15+)
MERGE INTO products p
USING staged_products s ON s.sku = p.sku
WHEN MATCHED THEN UPDATE SET price = s.price
WHEN NOT MATCHED THEN INSERT (sku, name, price) VALUES (s.sku, s.name, s.price);

Remember

  • Never SELECT-then-INSERT for upserts; it races under concurrency
  • ON CONFLICT needs a unique index on the conflict target; EXCLUDED is the proposed row
  • DO NOTHING makes inserts idempotent
  • MERGE suits batch syncs; ON CONFLICT is safer for concurrent upserts in PostgreSQL

Watch out for

  • Including the same key twice in one multi-row upsert, which fails with 'cannot affect row a second time'.

Check yourself

  1. In ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, what is EXCLUDED?
  2. Why is 'SELECT the row; if missing INSERT, else UPDATE' unsafe in application code?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT