studyDDIAChapter 8 · Transactions

Compare-and-Set: the Loser Silently Gets UPDATE 0

Two sessions both read version = 5 and both run the same guarded write UPDATE docs SET content=…, version=6 WHERE id=1 AND version=5. Predict both report UPDATE 1. Only one does — the loser reports UPDATE 0 and must retry, a lost update prevented with no lock. Raise the isolation level to REPEATABLE READ and the same write instead aborts with SQLSTATE 40001.


Concept

Two clients open the same document, both read version = 5, both edit, both save. To prevent one from silently clobbering the other (a lost update), each save is written as a compare-and-set: UPDATE docs SET content=…, version=6 WHERE id=1 AND version=5 — write only if the row is still at the version I read. Run both saves against a real PostgreSQL 16 and only one reports UPDATE 1. The loser reports UPDATE 0: by the time it runs, the winner has already bumped the row to version 6, so the loser's WHERE … AND version=5 matches nothing. A lost update is prevented with no lock at all — the loser just learns it lost and must re-read and retry. Then raise the isolation level to REPEATABLE READ and the same compare-and-set does something different again: instead of UPDATE 0 it aborts the whole transaction with ERROR: could not serialize access due to concurrent update (SQLSTATE 40001).

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 8 — Transactions, §"Conditional writes (compare-and-set)":

The purpose of this operation is to avoid lost updates by allowing an update to happen only if the value has not changed since you last read it. If the current value does not match what you previously read, the update has no effect, and the read-modify-write cycle must be retried.

The book also flags the trap: if the database lets the WHERE clause read from an old snapshot, the compare can pass against stale data and the update is lost anyway — so "check whether your database's compare-and-set operation is safe before relying on it." This exercise checks Postgres's, and finds it safe two different ways depending on isolation level.

Prerequisites

The surprise turns on when an UPDATE's WHERE clause is evaluated. Each line notes where it shows up.

  1. Optimistic concurrency control / compare-and-set — instead of locking a row while you think, you read it, prepare your write, and commit the write guarded by a check that nothing changed. It shows up as the version column in every WHERE clause.
  2. The read-modify-write race (lost update) — two transactions read the same value, both compute a new one from it, and the second write overwrites the first with no trace. It shows up as the thing the guard is there to stop — and as the UPDATE 0 that stops it.
  3. The UPDATE-WHERE visibility rule (the subtle one) — an UPDATE's WHERE is not simply evaluated against your transaction's read snapshot. Postgres finds candidate rows by the snapshot, then, before writing, re-checks each against the latest committed version of the row. Under READ COMMITTED that re-check quietly filters the row out (UPDATE 0); under REPEATABLE READ it can't quietly proceed, so it aborts with SQLSTATE 40001. This rule is the whole reason compare-and-set is safe here — and it's the one most people predict wrong.
Where to learn the prerequisites Compare-and-set and lost updates (#1–#2): DDIA §"Preventing Lost Updates" → §"Compare-and-set"; PostgreSQL manual §13.2 "Transaction Isolation". The UPDATE-WHERE re-check (#3): PostgreSQL manual §13.2.1 (Read Committed — "EvalPlanQual", how UPDATE re-reads a concurrently-updated row) and §13.2.2 (Repeatable Read — why it raises serialization_failure instead). If only one is new, make it #3 — the moment an UPDATE's WHERE is checked against the latest committed row rather than your snapshot is the whole mechanism.
Environment these numbers came from Deterministic, so the rowcounts and the error reproduce exactly. PostgreSQL 16.14 (Debian, aarch64) in Docker on macOS 26.5.2 (Darwin 25.5.0), arm64; captured via psycopg[binary] on Python 3.12 (uv); deterministic. Shared server ddia-ch08-pg on localhost:5432, user postgres, trust auth; this exercise works in its own database ex_ch08_cas. One row: docs(id=1, content='draft', version=5).

Mental model

Compare-and-set is one sentence: "write only if this row is unchanged since I read it." The version column is how "unchanged" is spelled — every writer reads the version, then guards its write with WHERE id=1 AND version=<what I read> and sets version = <that + 1>.

The subtlety is how the loser's guard "sees" the concurrent commit. A plain SELECT in a REPEATABLE READ transaction is frozen on a snapshot and would still show the old version — but an UPDATE's WHERE is re-checked against the newest committed row, which is why the guard works even where a bare read wouldn't.

code/compare_and_set.py is the capture harness: it drives these same two sessions programmatically (psycopg) so the transcripts below are real, verbatim output. You run the exercise by hand in two psql sessions.

Setup

# The shared server (already running for this lab -- do not restart it):
docker run -d --name ddia-ch08-pg -p 5432:5432 \
  -e POSTGRES_HOST_AUTH_METHOD=trust postgres:16

# Create your own database and the guarded table:
docker exec ddia-ch08-pg psql -U postgres -c "CREATE DATABASE ex_ch08_cas;"
docker exec ddia-ch08-pg psql -U postgres -d ex_ch08_cas -c \
  "CREATE TABLE docs (id int PRIMARY KEY, content text, version int);
   INSERT INTO docs VALUES (1, 'draft', 5);"

# Open TWO psql sessions, in two terminals -- this is Session A and Session B:
#   Session A:  docker exec -it ddia-ch08-pg psql -U postgres -d ex_ch08_cas
#   Session B:  docker exec -it ddia-ch08-pg psql -U postgres -d ex_ch08_cas

To re-capture the transcripts programmatically instead of by hand:

uv run --python 3.12 --with "psycopg[binary]" python3 code/compare_and_set.py

Do not read the output yet — make each prediction first.


Step 1 · both sessions read the same version

Predict. In two separate psql sessions, both run the same read before anyone writes. What version does each see?

-- Session A:
SELECT version FROM docs WHERE id=1;
-- Session B:
SELECT version FROM docs WHERE id=1;
What version does each session read?
Session A: SELECT version FROM docs WHERE id=1; version --------- 5 (1 row) Session B: SELECT version FROM docs WHERE id=1; version --------- 5 (1 row)

Both see 5 — the shared starting point. Each is about to compute a new document from this same version 5. That is the exact setup for a lost update; the guard is what will stop it.

Step 2 · both run the guarded write (the surprise)

Predict. Both sessions now save with a compare-and-set: guard on the version they read (5), bump to 6. Session A runs first, then Session B. Predict the tag each UPDATE prints. (Most people say UPDATE 1 for both — they both read 5, and 5 is what they're checking for.)

-- Session A:
UPDATE docs SET content='A wins', version=6 WHERE id=1 AND version=5;
-- Session B (after A):
UPDATE docs SET content='B wins', version=6 WHERE id=1 AND version=5;
What tag does each UPDATE print?
Session A: UPDATE docs SET content='A wins', version=6 WHERE id=1 AND version=5; UPDATE 1 Session B: UPDATE docs SET content='B wins', version=6 WHERE id=1 AND version=5; UPDATE 0

Not both — UPDATE 1 then UPDATE 0. A's guard matched (the row was still at 5), so A wrote and the version is now 6. When B runs the identical statement, its WHERE … AND version=5 no longer matches any row — the latest committed version is 6. B changes nothing, gets UPDATE 0, and would re-read and retry. No lock was taken, no one waited, and B's blind overwrite of A never happened.

Step 3 · the snapshot sub-surprise, under REPEATABLE READ

Predict. Same race, but Session A now runs inside a REPEATABLE READ transaction: it SELECTs version 5 (taking its snapshot), then Session B commits a bump to 6 in its own transaction, then Session A — still in the same transaction — first re-SELECTs the version, then runs its guarded UPDATE … WHERE version=5. Predict two things: what does A's second SELECT show, and what does A's UPDATE report?

-- Session A:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT version FROM docs WHERE id=1;
-- Session B (autocommit), in between:
UPDATE docs SET content='B bumped', version=6 WHERE id=1 AND version=5;
-- Session A again, same transaction:
SELECT version FROM docs WHERE id=1;
UPDATE docs SET content='A wins', version=6 WHERE id=1 AND version=5;
COMMIT;
What does A's second SELECT show, and what does A's UPDATE report?
Session A: BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ; BEGIN Session A: SELECT version FROM docs WHERE id=1; version --------- 5 (1 row) Session B: UPDATE docs SET content='B bumped', version=6 WHERE id=1 AND version=5; UPDATE 1 Session A: SELECT version FROM docs WHERE id=1; -- still the snapshot version --------- 5 (1 row) Session A: UPDATE docs SET content='A wins', version=6 WHERE id=1 AND version=5; ERROR: could not serialize access due to concurrent update Session A: COMMIT; ROLLBACK

The SELECT still says 5, but the UPDATE doesn't return UPDATE 0 — it throws. The frozen snapshot is real: A's re-read still shows version 5, because REPEATABLE READ pins every plain read to the transaction's opening snapshot. But the UPDATE's guard is checked against the latest committed row, which B moved to 6. Under REPEATABLE READ, Postgres will not silently re-evaluate that guard against a row your snapshot can't see — that would violate snapshot isolation — so it refuses the write and aborts the whole transaction with SQLSTATE 40001 (serialization_failure). COMMIT on an aborted transaction reports ROLLBACK. Same lost update prevented; a louder retry signal.


What you should see

Why

A blind write — UPDATE docs SET content=…, version=6 WHERE id=1 — has no memory of what you read. Two of them racing is the textbook lost update: whoever writes second wins, and the first edit is gone with nothing logged. Compare-and-set turns that blind write into a guarded one by folding the precondition into the WHERE: AND version=5 says "…but only if this row is still the one I read." The guard is a predicate the database evaluates atomically as part of the write, so it can't be raced the way a separate "check, then write" could.

The mechanism that makes it correct is the one people mispredict: an UPDATE's WHERE is evaluated against the latest committed version of the row, not against your read snapshot. Postgres finds candidate rows using your snapshot, but before modifying one it re-reads the newest committed tuple and re-applies the predicate to that. So once A commits version 6, B's AND version=5 is tested against a row that now says 6, matches nothing, and B writes nothing. The guard changed underneath B precisely because a concurrent writer changed the row — which is exactly the condition compare-and-set exists to detect. That B read 5 a moment earlier is irrelevant; the write path re-checks against reality.

The two isolation levels differ only in how they deliver the "you lost, retry" message. Under READ COMMITTED, that re-check against the latest row is allowed to simply drop the non-matching row, so B gets a quiet UPDATE 0. Under REPEATABLE READ, silently substituting a newer row for the one in your snapshot would break the guarantee that your transaction sees a single consistent snapshot — so Postgres instead aborts with serialization_failure (40001) and makes you retry the whole transaction. Both are safe compare-and-set; one hands you a rowcount to branch on, the other hands you an error to catch.

The boundary — the guard protects nothing unless every writer uses it Compare-and-set is a cooperative protocol: the version column only stops lost updates because all writers agree to read it, guard on it, and bump it. A single blind UPDATE docs SET content=… WHERE id=1 (no version in the WHERE, or one that doesn't advance it) sails straight through and clobbers a concurrent CAS writer — the guard it skipped was the only thing making the row safe. And the trap DDIA names still applies to other engines: a database whose UPDATE … WHERE does read the guard from a stale snapshot would let B's version=5 pass against already-changed data, losing the update anyway. That is why the book says to verify your database's compare-and-set is safe rather than assume it — on Postgres 16 it is, by re-checking the guard against the latest committed row (READ COMMITTED) or refusing to proceed at all (REPEATABLE READ).

Go deeper

Sources: DDIA 2e, Ch. 8, §"Preventing Lost Updates", §"Conditional writes (compare-and-set)" · PostgreSQL 16 manual, §13.2 "Transaction Isolation" (Read Committed / Repeatable Read).