Transactions & isolation: concurrent writers without lost updates
- Explain the all-or-nothing guarantee in practice: why an error inside a transaction turns
COMMITinto 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
A transaction is all or nothing
FlagMoving 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
COMMITreturns, the change survives a crash (with the defaultsynchronous_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
READ COMMITTED, statement by statement
FlagREAD 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
UPDATEre-checks itsWHERE, 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.
Invariants across rows — write skew, check-then-insert, and SERIALIZABLE
FlagRow 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 retrying40P01(deadlock_detected) too. Treat23505as 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?
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?
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?
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?
FlagRule: 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?
FlagA SERIALIZABLE transaction reads a balance, decides the transfer is allowed, writes, and gets 40001 at COMMIT. What exactly should the retry re-run?
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?
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?
FlagFill 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.
FlagBEGIN; 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.
UPDATE products SET stock = $2, version = + 1 WHERE id = $1 AND = $3;
Let one optional insert fail without losing the rest of the transaction.
FlagBEGIN; 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.
FlagALTER TABLE signups ADD CONSTRAINT signups_email_key (email);
Lock both accounts of a transfer up front, in an order every transaction agrees on.
FlagSELECT 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
-- 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;
-- 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;
-- 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
Challenge
Make a stock reservation safe under concurrent writers
FlagreserveStock 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
quantityand 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
OutOfStockErrorwithout 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
OutOfStockErrorand writes nothing - It gives up with
ConflictErrorafter exactlymaxAttemptsconflicting writes
Further reading
- PostgreSQL 18 docs: Transactions (tutorial)
- PostgreSQL 18 docs: Transaction Isolation
- PostgreSQL 18 docs: Explicit Locking
- PostgreSQL 18 docs: Serialization Failure Handling
- PostgreSQL 18 docs: SELECT (the locking clause)
- PostgreSQL 18 docs: Client Connection Defaults (lock_timeout)
- PostgreSQL 18 docs: Lock Management (deadlock_timeout)
- PostgreSQL 18 docs: Asynchronous Commit (durability)