Databases
Databases

Transactions & isolation: concurrent writers without lost updates

Full · ~45 min
After this unit you can
  • Explain the all-or-nothing guarantee in practice: why an error inside a transaction turns COMMIT into a rollback, and when a savepoint helps
  • Predict what READ COMMITTED — PostgreSQL's default — lets a transaction see: a fresh snapshot per statement, and a write that waits for a locked row then re-checks its WHERE
  • Spot a lost update in read-modify-write code and fix it with an atomic UPDATE, SELECT … FOR UPDATE, or a version column with a bounded retry
  • Protect a rule that spans rows with a constraint or SERIALIZABLE, and retry the whole transaction on 40001
  • Prevent deadlocks by locking rows in a consistent order, and bound lock waits with lock_timeout

Read

Simple
1 / 2

A transaction is all or nothing

Flag

Moving 10 from account 1 to account 2 is two statements. If the server dies between them, 10 has vanished. A transaction bundles statements so that either all of them take effect or none do:

BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
COMMIT;      -- both changes become visible to everyone, together

ROLLBACK instead of COMMIT throws both away. Without BEGIN, PostgreSQL wraps every single statement in its own transaction (an implicit BEGIN and, if it succeeds, COMMIT).

The guarantees go by ACID:

  • Atomic — all of it or none of it.
  • Consistent — constraints (NOT NULL, foreign keys, CHECK, UNIQUE) hold when it commits.
  • Isolated — other sessions don't see your changes until you commit, and you see theirs only in the ways your isolation level allows (the rest of this unit).
  • Durable — once COMMIT returns, the change survives a crash (with the default synchronous_commit = on).

An error poisons the whole transaction

BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
INSERT INTO accounts VALUES (1, 0);
-- ERROR 23505: duplicate key value violates unique constraint "accounts_pkey"
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
-- ERROR 25P02: current transaction is aborted, commands ignored until end of transaction block
COMMIT;
-- the server answers ROLLBACK

After any error the transaction is aborted: every following statement is refused, and COMMIT rolls back — run from Node with pg, the COMMIT doesn't even throw; its result's command is ROLLBACK. So if your code catches an error inside a transaction, logs it and carries on, the "successful" commit at the end silently discarded everything. On error: roll back and retry or report — never continue.

When one step is genuinely allowed to fail, mark a savepoint and roll back to just that:

BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
SAVEPOINT before_insert;
INSERT INTO accounts VALUES (1, 0);              -- fails
ROLLBACK TO SAVEPOINT before_insert;             -- the transaction is usable again
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
COMMIT;                                          -- both updates commit
Medium
1 / 2

READ COMMITTED, statement by statement

Flag

READ COMMITTED is PostgreSQL's default isolation level, and most application code runs under it without knowing. Two rules describe almost everything it does.

Rule 1: every statement takes a fresh snapshot

A statement sees the data as committed at the instant that statement starts (plus your own uncommitted changes). The next statement in the same transaction takes a new snapshot:

A: BEGIN;
A: SELECT balance FROM accounts WHERE id = 1;     -- 100
B: UPDATE accounts SET balance = 50 WHERE id = 1; -- B autocommits
A: SELECT balance FROM accounts WHERE id = 1;     -- 50  (same transaction, new answer)
A: COMMIT;

That's a non-repeatable read, and READ COMMITTED allows it. Never assume a value you SELECTed earlier in the transaction is still true when you write.

You never see another transaction's uncommitted work, though — no dirty reads at any level in PostgreSQL (asking for READ UNCOMMITTED gets you READ COMMITTED).

Rule 2: a write to a row someone else is changing waits, then re-checks

When an UPDATE, DELETE or SELECT … FOR UPDATE reaches a row that another open transaction has already changed, it waits for that transaction to finish. If the other one commits, the waiting statement takes the new version of the row, re-evaluates its WHERE clause against it, and applies its change to that version if it still matches (or skips the row if it doesn't).

That rule is why the one-statement fix from the simple tier works:

A: BEGIN;
A: UPDATE posts SET comment_count = comment_count + 1 WHERE id = 1;  -- 5 → 6, row locked
B: UPDATE posts SET comment_count = comment_count + 1 WHERE id = 1;  -- waits…
A: COMMIT;
B:                                     -- …re-reads the committed row (6), writes 7

And it's why a guarded update is safe — the guard is re-checked against the row as it is now:

-- stock is 3; both want 2
A: BEGIN;
A: UPDATE products SET stock = stock - 2 WHERE id = 1 AND stock >= 2 RETURNING stock;  -- 1
B: UPDATE products SET stock = stock - 2 WHERE id = 1 AND stock >= 2 RETURNING stock;  -- waits
A: COMMIT;
B:                                     -- re-checks: stock is 1, 1 >= 2 is false → UPDATE 0

UPDATE 0 is the answer "not enough stock" — check the row count in your code. A CHECK (stock >= 0) constraint is the backstop for any writer that forgets the guard.

What READ COMMITTED does not protect

It re-checks the row being written, not the other rows you read to decide what to write. The PostgreSQL manual says it plainly: an updating command can see the effects of concurrent updates on the rows it's updating, but not their effects on other rows. So:

  • read-modify-write in the application (read 5, compute 6, write 6) still loses updates — the UPDATE re-checks its WHERE, but the value it writes was computed from the old read;
  • a decision based on other rows ("is anyone else on call?", "does this email exist yet?") can be invalidated by a concurrent transaction and nothing notices.

The next tier is the toolkit for both.

Complex
1 / 2

Invariants across rows — write skew, check-then-insert, and SERIALIZABLE

Flag

Row locks and version checks protect the row you write. Some rules are about other rows.

Write skew: both checks pass, the rule breaks

Rule: at least one doctor stays on call. Alice and Bob are both on call; each asks to go off duty at the same moment, through the same code:

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM on_call WHERE on_duty;                      -- 2, so it's safe to leave
UPDATE on_call SET on_duty = false WHERE doctor = 'alice';       -- (Bob's session: 'bob')
COMMIT;

Run two sessions side by side — both checks, then both updates, then both commits — and both commit: zero doctors on call. Each transaction's check was true in its own snapshot, and they wrote different rows, so no row lock or version check ever collided. READ COMMITTED and REPEATABLE READ both allow it (tested). Under SERIALIZABLE the same interleaving ends with:

B: COMMIT;
ERROR 40001: could not serialize access due to read/write dependencies among transactions

SERIALIZABLE guarantees the result matches some one-at-a-time order of the committed transactions. It tracks what each transaction read, notices that each one's read was invalidated by the other's write, and fails one so the invariant survives (one doctor stayed on call). Note the error can arrive at COMMIT, not only at a statement — and where it arrives depends on the timing: had A committed before B's UPDATE, that UPDATE would have been the statement that failed.

Check-then-insert: the same bug with INSERT

A: SELECT count(*) FROM signups WHERE email = 'ada@x.io';   -- 0
B: SELECT count(*) FROM signups WHERE email = 'ada@x.io';   -- 0
A: INSERT …;  B: INSERT …;  A: COMMIT;  B: COMMIT;          -- two Adas

Every "does it exist? no → create it" in application code has this race. Here the right fix isn't an isolation level — it's a constraint. With UNIQUE (email), B's insert waits for A, then fails with 23505 (under SERIALIZABLE, after a check that read the key, it fails with 40001 instead). Either way the duplicate never commits — at every isolation level and for every writer, including ones that skip the check. Reach for SERIALIZABLE when the invariant can't be written as a constraint (the on-call count can't be a UNIQUE or CHECK).

Retrying correctly

The manual's guidance, in practice:

  • Always retry 40001 (serialization_failure); consider retrying 40P01 (deadlock_detected) too. Treat 23505 as retryable only when your logic chose the conflicting value from a read (it may also be a permanent error).
  • Retry the complete transaction, including the reads and the application logic that decided what to write. Replaying just the failed statement replays the stale decision. That's why PostgreSQL offers no automatic retry.
  • Bound it. A few attempts with a short random back-off; then surface the error.
  • Keep side effects out. An email sent or an HTTP call made inside the transaction will happen once per attempt. Do them after COMMIT.

Quiz

Inside BEGIN … COMMIT, an INSERT throws a unique violation. The code catches it, logs it, runs two more updates and calls COMMIT, which doesn't throw. What was saved?

Flag

This runs once per new comment: SELECT comment_count … → compute count + 1 in Node → UPDATE posts SET comment_count = $1. Under load the count drifts low. Which fix is simplest and correct?

Flag

UPDATE products SET stock = $2, version = version + 1 WHERE id = $1 AND version = $3 reports 0 rows. What does that mean, and what should the code do?

Flag

An admin opens a product form at 10:00 and saves at 10:20. You must not silently overwrite someone else's 10:05 edit. Which approach fits?

Flag

Rule: at least one doctor stays on call. Each "go off duty" request counts doctors on duty, and if the count is above 1 updates its own row. Two run at once under REPEATABLE READ and both commit, leaving none. What prevents it?

Flag

A SERIALIZABLE transaction reads a balance, decides the transfer is allowed, writes, and gets 40001 at COMMIT. What exactly should the retry re-run?

Flag

Transfers A→B and B→A sometimes fail with 40P01 deadlock detected. Each transfer updates the source account, then the destination. What stops the deadlocks?

Flag

A request handler sometimes waits minutes for a row that a batch job has locked. Which setting makes the handler's statements give up after 2 seconds of waiting for a lock?

Flag

Fill in the blank

The reservation needs application logic between the read and the write. Lock the row as you read it, so a concurrent request waits instead of reading the same stock.

Flag
BEGIN;
SELECT stock FROM products WHERE id = $1 FOR ;
-- …decide in the application…
UPDATE products SET stock = $2 WHERE id = $1;
COMMIT;

An optimistic write: succeed only if nobody changed the row since you read version $3, and move the version on.

Flag
UPDATE products
SET stock = $2, version =  + 1
WHERE id = $1 AND  = $3;

Let one optional insert fail without losing the rest of the transaction.

Flag
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
 before_insert;
INSERT INTO audit_tags VALUES (1, 'promo');   -- may hit a unique violation
ROLLBACK  SAVEPOINT before_insert;
COMMIT;

Two signups for the same email run "does it exist? no → insert" at the same moment. Make a duplicate impossible for every writer, at any isolation level.

Flag
ALTER TABLE signups ADD CONSTRAINT signups_email_key  (email);

Lock both accounts of a transfer up front, in an order every transaction agrees on.

Flag
SELECT id FROM accounts WHERE id IN ($1, $2)  FOR UPDATE;

Predict the output

-- default isolation (READ COMMITTED); balance starts at 100
A: BEGIN;
A: SELECT balance FROM accounts WHERE id = 1;
B: UPDATE accounts SET balance = 50 WHERE id = 1;   -- autocommits
A: SELECT balance FROM accounts WHERE id = 1;       -- same transaction
Flag
-- comment_count = 5. Two requests, each in its own transaction:
A: BEGIN;  B: BEGIN;
A: SELECT comment_count FROM posts WHERE id = 1;   -- app computes 5 + 1
B: SELECT comment_count FROM posts WHERE id = 1;   -- app computes 5 + 1
A: UPDATE posts SET comment_count = 6 WHERE id = 1;
B: UPDATE posts SET comment_count = 6 WHERE id = 1;  -- waits for A's lock
A: COMMIT;  B: COMMIT;
Flag
-- stock = 3. Both want 2.
A: BEGIN;
A: UPDATE products SET stock = stock - 2 WHERE id = 1 AND stock >= 2;  -- UPDATE 1
B: UPDATE products SET stock = stock - 2 WHERE id = 1 AND stock >= 2;  -- blocks
A: COMMIT;
Flag
-- on_call: alice (on duty), bob (on duty). Both sessions:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM on_call WHERE on_duty;          -- each sees 2
UPDATE on_call SET on_duty = false WHERE doctor = …;  -- A: 'alice', B: 'bob'
COMMIT;                                                -- A, then B
Flag

Challenge

Make a stock reservation safe under concurrent writers

Flag

reserveStock takes stock from a product row using optimistic concurrency. product-store.ts stands in for this table and these two statements:

-- products(id text PRIMARY KEY, stock int, version int)

SELECT id, stock, version FROM products WHERE id = $1;              -- store.read(id)

UPDATE products SET stock = $3, version = version + 1
WHERE id = $1 AND version = $2;                                       -- store.updateStockIfVersion(…)

The UPDATE only matches if the row still has the version you read. If another transaction committed in between, the version moved on, nothing matches, and the statement reports 0 rows.

The current code was written as if nobody else ever writes: it reads, checks, writes, and returns — without looking at the row count. Under load that means overselling and "successful" reservations that were never saved. The tests simulate the other writer with store.otherWriterCommitsBeforeNextWrite(id, stock) (one transaction commits between your read and your write) and store.otherWriterCommitsBeforeEveryWrite() (you can never win).

Your task

Edit reserve-stock.ts so the tests pass:

  • With no other writer, the stock drops by quantity and the function resolves to the row as stored (new version included)
  • Reserving exactly the remaining stock succeeds (2 of 2 leaves 0)
  • Short stock at the first read rejects with OutOfStockError without attempting a write
  • A versioned write that changed 0 rows is never reported as success
  • After another writer commits first, it re-reads and retries, so both writes count (10 → 7 by them → 5 by you)
  • Stock is re-checked on every attempt: if the other writer left too little, it rejects with OutOfStockError and writes nothing
  • It gives up with ConflictError after exactly maxAttempts conflicting writes

Loading the in-browser editor…

Further reading