SQL for Backend Engineers · Safe Writes: Upserts and Transactions

Upserts, MERGE and Transactions

ON CONFLICT DO UPDATE and DO NOTHING, MERGE, writes with RETURNING, conditional updates that can't overdraw, isolation levels, and SELECT ... FOR UPDATE SKIP LOCKED.

  • 11 min read
  • 13 practice questions
  • 3 graded problems
On this page
  1. Description
  2. Let the database do the arithmetic
  3. Upserts: INSERT ... ON CONFLICT
  4. MERGE, and writes that join
  5. Transactions
  6. Row locks and job queues
  7. Which write tool?
  8. Where it breaks
  9. Tradeoffs
  10. Check yourself
  11. Key takeaways
  12. Quick revise
  13. Practice

Description

Reading data is forgiving: a slow or clumsy query still returns the right rows. Writing is where backends lose money. Most write bugs never show up in testing, because they need two requests at the same moment, or a crash halfway through:

  • Two customers buy the last mug. Both requests read qty = 1, both see stock, both write qty = 0. Two sales, one mug.
  • A double-clicked signup runs "is this email taken? no - insert" twice, and both inserts pass the check.
  • A nightly catalog sync updates prices, inserts new products, then crashes before deactivating old ones - leaving a catalog that matches neither yesterday nor today.

The fixes all live in the database, and they're not hard once you've seen them. In order:

  1. Doing the check and the change in one statement.
  2. Upserts with INSERT ... ON CONFLICT.
  3. MERGE, and writes that join other tables.
  4. Transactions, savepoints and isolation levels.
  5. Row locks, and job queues with SKIP LOCKED.

Examples use inventory, page_views, products, a price_feed and a jobs queue; every script ran in the sandbox on PostgreSQL 18.

Let the database do the arithmetic

The race above is a lost update: read a value into the app, compute, write the result back. Between the read and the write, someone else's write is overwritten:

Request Binventory (qty = 1)Request ARequest Binventory (qty = 1)Request Aboth see one mug in stocktwo sales, one mug - B's write erased A'sSELECT qty1SELECT qty1UPDATE SET qty = 0UPDATE SET qty = 0
Request Binventory (qty = 1)Request ARequest Binventory (qty = 1)Request Aboth see one mug in stocktwo sales, one mug - B's write erased A'sSELECT qty1SELECT qty1UPDATE SET qty = 0UPDATE SET qty = 0

Put the arithmetic and the condition in the UPDATE itself:

UPDATE inventory
SET qty = qty - 1
WHERE sku = 'MUG-01' AND qty >= 1;

UPDATE inventory
SET qty = qty - 1
WHERE sku = 'TEA-02' AND qty >= 1;   -- out of stock: updates 0 rows
skuqty
MUG-014
TEA-020

Postgres locks the row it updates, so two concurrent decrements run one after the other - and the second re-checks qty >= 1 against the first one's result. The app learns the outcome from the row count: 1 means sold, 0 means out of stock. A CHECK (qty >= 0) is the backstop that turns any remaining bug into an error instead of negative stock.

Upserts: INSERT ... ON CONFLICT

"Insert, or update if it exists" as one statement:

INSERT INTO page_views AS v (page, views)
VALUES ('/pricing', 1), ('/docs', 1)
ON CONFLICT (page) DO UPDATE
SET views = v.views + EXCLUDED.views;
pageviews
/docs1
/pricing11

EXCLUDED is the row the insert proposed; the table's alias (v) is the row already there. Unlike "select, then insert or update" in the app, ON CONFLICT is safe when two sessions upsert the same new key at once: one inserts, the other waits and then updates. ON CONFLICT DO NOTHING skips existing keys instead - and its RETURNING returns nothing for them, so select the existing row separately if you need its id.

Two rules to know:

  • The conflict target needs a matching unique index or constraint - ON CONFLICT (name) on a column without one fails: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification. (For a unique index on lower(email), the target is (lower(email)).)
  • One statement can't update the same row twice:
INSERT INTO page_views AS v (page, views)
VALUES ('/docs', 1), ('/docs', 1)
ON CONFLICT (page) DO UPDATE
SET views = v.views + EXCLUDED.views;

ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time

Batches from queues and logs often repeat a key - aggregate them first (SELECT key, SUM(n) ... GROUP BY key) and upsert one row per key.

MERGE, and writes that join

MERGE (Postgres 15+) compares a source with a target and runs a different action for each case. Since Postgres 17 it can act on target rows missing from the source (WHEN NOT MATCHED BY SOURCE) and return what it did; since 18, RETURNING can read the old and new values:

CREATE TABLE price_log (action text, sku text, old_price numeric(10,2), new_price numeric(10,2));

WITH changed AS (
    MERGE INTO products p
    USING price_feed f ON f.sku = p.sku
    WHEN MATCHED AND p.price <> f.price THEN
        UPDATE SET price = f.price
    WHEN NOT MATCHED THEN
        INSERT (sku, name, price) VALUES (f.sku, 'New ' || f.sku, f.price)
    WHEN NOT MATCHED BY SOURCE THEN
        UPDATE SET active = false
    RETURNING merge_action() AS action, p.sku, old.price AS old_price, new.price AS new_price
)
INSERT INTO price_log SELECT * FROM changed;
SELECT action, sku, old_price, new_price FROM price_log ORDER BY sku;
actionskuold_pricenew_price
INSERTCUP-04NULL6.50
UPDATEMUG-0112.0014.00
UPDATEPEN-032.002.00

MUG-01's price changed, CUP-04 is new, PEN-03 isn't in the feed so it was deactivated (its price didn't change), and TEA-02 matched with the same price, so no clause fired. Clauses are tried in order and the first whose condition holds wins - put the specific WHEN MATCHED AND ... before a catch-all WHEN MATCHED.

MERGE is for reconciling data, not for racing upserts: if two sessions MERGE the same new key at once, one fails with a unique violation. For concurrent "insert or update", use ON CONFLICT.

Plain UPDATE ... FROM other_table and DELETE ... USING other_table join inside a write, and RETURNING on any write hands back the rows it touched - with old and new in Postgres 18, an audit trail needs no second query:

CREATE TABLE stock_moves (sku text, old_qty int, new_qty int);

WITH moved AS (
    UPDATE inventory SET qty = qty - 2
    WHERE sku = 'MUG-01'
    RETURNING sku, old.qty AS old_qty, new.qty AS new_qty
)
INSERT INTO stock_moves SELECT * FROM moved;
skuold_qtynew_qty
MUG-0153

Transactions

BEGIN ... COMMIT makes several statements one unit: they all take effect, or - on ROLLBACK or any error - none do.

BEGIN;
UPDATE inventory SET qty = qty + 10 WHERE sku = 'TEA-02';
UPDATE inventory SET qty = qty - 10 WHERE sku = 'MUG-01';   -- 5 - 10 breaks the CHECK
COMMIT;

ERROR: new row for relation "inventory" violates check constraint "inventory_qty_check"

The first UPDATE succeeded, but the transaction failed, so TEA-02 still has 0. After an error, a transaction accepts nothing but ROLLBACK. A SAVEPOINT marks a point you can roll back to without abandoning the whole transaction:

BEGIN;
UPDATE inventory SET qty = qty + 10 WHERE sku = 'TEA-02';
SAVEPOINT before_mug;
UPDATE inventory SET qty = qty - 1 WHERE sku = 'MUG-01';
ROLLBACK TO SAVEPOINT before_mug;
COMMIT;
skuqty
MUG-015
TEA-0210

Isolation decides what a transaction sees of others running at the same time:

leveleach statement seesif a row you read is changed by another transaction
READ COMMITTED (default)everything committed before that statement beganan UPDATE waits, then re-checks its WHERE against the new version
REPEATABLE READone snapshot, from the transaction's first statementupdating that row fails: could not serialize access
SERIALIZABLEone snapshot, and the result must match some one-at-a-time orderthe transaction may fail with SQLSTATE 40001

A transaction at READ COMMITTED does not stop the lost update: two transactions can both SELECT qty (1) and both write qty = 0. Either write atomically as above, lock the row first (next section), or use REPEATABLE READ/SERIALIZABLE - and retry the whole transaction when it fails with a serialization error.

Row locks and job queues

SELECT ... FOR UPDATE locks the rows it returns until the transaction ends; another transaction that tries to update or lock them waits. That's the tool when the decision really must happen in the app: lock, read, decide, write, commit.

Queue workers add SKIP LOCKED: each worker takes the next job no one else holds, instead of waiting behind a job another worker is busy with:

WITH next_job AS (
    SELECT id FROM jobs
    WHERE status = 'queued'
    ORDER BY run_at
    LIMIT 1
    FOR UPDATE SKIP LOCKED
)
UPDATE jobs SET status = 'running'
FROM next_job
WHERE jobs.id = next_job.id;
idstatus
1running
2queued
3done

A second worker running the same statement at the same moment would skip job 1 and claim job 2. (NOWAIT fails immediately instead of waiting.)

Locks bring deadlocks: a transfer from A to B locks A then B, while one from B to A locks B then A - each waits for the other, and Postgres aborts one. Lock rows in a consistent order (by id) and keep transactions short - never wait on a user or a network call while holding locks.

Which write tool?

SituationUse
Change a value based on its current value (stock, balance, counter)One UPDATE ... SET x = x - 1 WHERE x >= 1, and read the row count
Insert, or update if the key exists - possibly concurrentlyINSERT ... ON CONFLICT (key) DO UPDATE
Insert once, ignore repeats (an idempotent request)INSERT ... ON CONFLICT DO NOTHING
Sync a table with a source: insert, update and retire in one passMERGE (run it from one process at a time)
Several changes that must succeed or fail togetherA transaction
The app must read, decide, then writeSELECT ... FOR UPDATE inside a short transaction
Many workers taking jobs from one tableFOR UPDATE SKIP LOCKED
A rule across several rows ("at least one doctor on call")SERIALIZABLE, with retries

Where it breaks

  • Read, compute in the app, write back - lost updates, even inside a READ COMMITTED transaction.
  • "Check, then insert" - two requests both pass the check; use a unique constraint and ON CONFLICT.
  • Upserting a batch with repeated keys - aggregate the source first.
  • MERGE for concurrent upserts - it can fail with a unique violation; use ON CONFLICT.
  • SERIALIZABLE without retries - serialization failures are expected; retry the transaction.
  • Locks in different orders, or held across slow work - deadlocks and pile-ups.

Tradeoffs

  • ON CONFLICT vs MERGE: ON CONFLICT is the concurrency-safe upsert on one unique key; MERGE expresses many rules at once (insert, update, deactivate) for syncs that run alone.
  • Atomic UPDATE vs FOR UPDATE vs SERIALIZABLE: the atomic statement is simplest and fastest when the rule fits in a WHERE; FOR UPDATE when the app must decide; SERIALIZABLE protects complex multi-row rules at the cost of retries.
  • One big transaction vs batches: one transaction is all-or-nothing, but holds locks longer; big backfills usually run in batches that are each safe to repeat.

Going further. The deep dive (next tab) opens up MVCC - why an UPDATE writes a new row, what that does to table size, HOT updates and fillfactor, vacuum - plus write skew, idempotency keys, the transactional outbox, advisory locks, and how other engines differ.

Check yourself

  1. Why doesn't wrapping "SELECT balance, then UPDATE with the new balance" in BEGIN/COMMIT prevent a lost update?
Show answer

At READ COMMITTED, both transactions can read the same balance before either writes; the second UPDATE waits for the first, then overwrites it with a value computed from the stale read. Update atomically (SET balance = balance - 50 WHERE balance >= 50), lock with SELECT ... FOR UPDATE, or use a stricter isolation level with retries.

  1. A batch upsert fails with ON CONFLICT DO UPDATE command cannot affect row a second time. What's wrong with the batch?
Show answer

It contains the same key more than once. Aggregate (or deduplicate) the source so each key appears once, then upsert.

  1. What does SKIP LOCKED add to SELECT ... FOR UPDATE LIMIT 1 in a job queue?
Show answer

Rows locked by another worker are skipped instead of waited for, so each worker claims a different job and none sits blocked behind another's.

Key takeaways

  • Put the check and the change in one statement: UPDATE ... SET qty = qty - 1 WHERE qty >= 1, and read the row count.
  • Upsert with INSERT ... ON CONFLICT (key) DO UPDATE SET col = t.col + EXCLUDED.col; it needs a unique index, and one row per key in the source.
  • MERGE reconciles a source and a target - including rows missing from the source - and RETURNING (with old/new in Postgres 18) records what changed.
  • Transactions are all-or-nothing; isolation decides what they see. READ COMMITTED alone doesn't prevent lost updates.
  • FOR UPDATE locks rows; SKIP LOCKED builds job queues; lock in a consistent order to avoid deadlocks.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Which write tool?

SituationUse
Change a value based on its current value (stock, balance, counter)One UPDATE ... SET x = x - 1 WHERE x >= 1, then read the row count
Insert, or update if the key exists - possibly concurrentlyINSERT ... ON CONFLICT (key) DO UPDATE
Insert once, ignore repeats (an idempotent request)INSERT ... ON CONFLICT DO NOTHING
Sync a table with a source: insert, update, retireMERGE (one process at a time)
Several changes that succeed or fail togetherA transaction
The app must read, decide, then writeSELECT ... FOR UPDATE in a short transaction
Many workers taking jobs from one tableFOR UPDATE SKIP LOCKED
A rule across several rows ("someone stays on call")SERIALIZABLE, with retries

Isolation levels

LevelEach statement seesWhen a row you read changes underneath you
READ COMMITTED (default)Everything committed before that statementAn UPDATE waits, then re-checks its WHERE on the new version
REPEATABLE READOne snapshot from the first statementUpdating that row fails: could not serialize access
SERIALIZABLEOne snapshot, and the result must match some serial orderMay fail with SQLSTATE 40001 - retry

READ COMMITTED alone does not prevent lost updates.

Snippets worth knowing by heart

-- sell one, only if in stock
UPDATE inventory SET qty = qty - 1 WHERE sku = $1 AND qty >= 1;     -- 1 row = sold, 0 = out

-- concurrency-safe counter upsert
INSERT INTO page_views AS v (page, views) VALUES ($1, 1)
ON CONFLICT (page) DO UPDATE SET views = v.views + EXCLUDED.views;

-- audit what changed (PostgreSQL 18: old / new in RETURNING)
UPDATE inventory SET qty = qty - 2 WHERE sku = 'MUG-01'
RETURNING sku, old.qty AS old_qty, new.qty AS new_qty;

-- claim the next job
WITH next_job AS (SELECT id FROM jobs WHERE status = 'queued' ORDER BY run_at
                  LIMIT 1 FOR UPDATE SKIP LOCKED)
UPDATE jobs SET status = 'running' FROM next_job WHERE jobs.id = next_job.id;

-- partial rollback
SAVEPOINT before_mug; ... ROLLBACK TO SAVEPOINT before_mug;

The traps

  • Read, compute in the app, write back - a lost update, even inside a transaction.
  • "Check, then insert" - both requests pass the check. Use a unique constraint plus ON CONFLICT.
  • ON CONFLICT needs a matching unique index, and a batch can't touch the same key twice ("cannot affect row a second time") - aggregate the batch first.
  • ON CONFLICT DO NOTHING ... RETURNING returns nothing for the existing row.
  • MERGE for concurrent upserts can fail with a unique violation.
  • After an error, a transaction accepts only ROLLBACK.
  • SERIALIZABLE without retry logic.
  • Locking in different orders, or holding locks across slow calls - deadlocks and pile-ups.

Staff-level points, one line each

  • MVCC: an UPDATE writes a new row version (new ctid) and ends the old one; readers never block writers. Updating every row of a table doubles its size until vacuum reuses the space.
  • VACUUM marks dead versions reusable but doesn't shrink the file; VACUUM FULL (locks) or pg_repack gives space back.
  • One long or idle in transaction session holds back vacuum for the whole database. Set idle_in_transaction_session_timeout; watch the oldest xact_start.
  • Transaction ids are 32-bit; if freezing can't keep up, PostgreSQL stops accepting writes (wraparound protection).
  • HOT updates skip index writes when no indexed column changes and the new version fits on the same page: fillfactor 100 gave 0 HOT updates, 70 gave 8,685 of 20,000. Indexing a hot column kills HOT.
  • REPEATABLE READ is snapshot isolation: it allows write skew (two doctors each go off call); SERIALIZABLE aborts one with 40001.
  • Idempotency keys: a unique key per request + ON CONFLICT DO NOTHING, and store the original response. Identity values burned by a conflict are never reused - gaps are normal.
  • Transactional outbox: write the event to an outbox table in the same transaction; a relay publishes it (at-least-once), consumers deduplicate.
  • Advisory locks (pg_try_advisory_xact_lock(42)) give mutual exclusion that isn't about a row - "one billing job at a time".
  • A table-backed queue at high rates churns dead rows: keep it small, partition or delete finished jobs, low fillfactor, partial index WHERE status = 'queued'.
  • MySQL InnoDB defaults to REPEATABLE READ with gap locks; Oracle's SERIALIZABLE is snapshot isolation and allows write skew.

Say it in an interview

  • "I'd put the stock check in the UPDATE's WHERE and use the row count - no read-then-write."
  • "Retries are safe because each payment carries an idempotency key with a unique constraint."
  • "That rule spans several rows, so REPEATABLE READ allows write skew; I'd use SERIALIZABLE with retries or lock the rows."
  • "The table is bloated because a long transaction is holding back vacuum - find it first."

Practice

This skill comes with 13 multiple-choice questions that tell you which misconception a wrong answer reveals and 3 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Upserts, MERGE and Transactions free

Upsert Daily Product Stats

MediumProSQL problem

The event pipeline counts product views and purchases and hands them over in batches, in stats_batch. Add the batch to product_daily_stats:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Transfer Money Without Overdrawing

MediumProSQL problem

A customer asks to move 250.00 from account 1 to account 2. Write the script the payments service runs for it:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Sync the Catalog With a Supplier Feed

HardProSQL problem

Every night the supplier sends its full product list, loaded into supplier_feed. Bring products in line with it:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Upserts, MERGE and Transactions free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime