Two doctors are on call; a rule says at least one must stay. Each opens a REPEATABLE READ transaction, counts 2 on call, decides it's safe to leave, and takes itself off. Both commit — zero on call, no error. That's write skew, and snapshot isolation doesn't stop it. SERIALIZABLE does: Postgres SSI aborts one transaction with a real 40001.
Two doctors, Alice and Bob, are both on call for the same shift. A rule the system must never break: at least one doctor stays on call. Each wants to go home. Each opens a REPEATABLE READ transaction, runs SELECT count(*) FROM doctors WHERE on_call = true AND shift_id = 1, sees 2, concludes "the other one is still on, so it's safe for me to leave," and takes itself off call. Both commit. Now zero doctors are on call — the invariant is violated, and no error was raised. This is write skew, and the surprise is that the isolation level literally named repeatable read does not prevent it, nor does Postgres's lost-update detection (the two transactions wrote different rows, so there is no write-write conflict to detect). You will trigger it by hand in two psql sessions, then close it two ways: SERIALIZABLE (Postgres SSI aborts one transaction with a real 40001 error) and SELECT … FOR UPDATE (which locks the rows each decision was based on).
Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 8 — Transactions, §"Write Skew and Phantoms" / §"Characterizing write skew":
If two transactions read the same objects, and then update some of those objects (different transactions may update different objects), a write skew anomaly can occur.
The book uses this exact on-call-doctors example: each transaction checks that enough doctors are on call, then removes itself, and snapshot isolation lets both proceed on a premise that the other transaction is busy invalidating. Here you reproduce it on a real PostgreSQL, watch the invariant break, and watch serializability close it.
The surprise is about which reads an isolation level protects. One line each, where it shows up; the last one carries the fix.
REPEATABLE READ level is snapshot isolation.)UPDATE and Bob's UPDATE touching disjoint rows and both committing.on_call = true AND shift_id = 1), not a fixed row; a concurrent write that changes which rows match the predicate is a phantom. It shows up as the count each transaction trusted becoming stale the instant the other commits.SERIALIZABLE adds tracking of read/write dependencies between transactions; if committing would produce a cycle that no serial order could, it aborts one with SQLSTATE 40001. This is the prerequisite that carries the fix — it is the only thing here that watches the rows you read to decide, not just the rows you write.40001 and the retry loop are documented. If only one is new, make it #4 — SSI's read/write-dependency tracking is the whole reason SERIALIZABLE catches what REPEATABLE READ cannot.
SERIALIZABLE → 40001, ≥1 on call) are 100% reproducible. 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. One shared Postgres container (ddia-ch08-pg, trust auth); this exercise works in its own database ex_ch08_writeskew.
Snapshot isolation makes one promise about reads and a different promise about writes, and write skew lives in the gap between them.
count = 2, because each is reading its own frozen picture where the other's change hasn't happened.The bug is that each transaction's decision depended on rows it only read (SELECT count(*) … on_call = true), and snapshot isolation does nothing to guarantee those still hold at commit. SSI (SERIALIZABLE) closes exactly this gap: it records the predicate each transaction read, notices that each transaction wrote something the other had read to make its decision — a read/write dependency cycle — and aborts one, because no serial order of the two could have produced this outcome.
The capture harness code/write_skew_doctors.py runs all three scenarios end to end and prints the same numbers you'll produce by hand; it is the maintainer's verification path, not a substitute for typing the interleaving yourself.
The Postgres container is already running (shared across Ch. 8 exercises). For reference, it was started with:
docker run -d --name ddia-ch08-pg -p 5432:5432 \
-e POSTGRES_HOST_AUTH_METHOD=trust postgres:16
Create this exercise's own database, table, and seed rows:
docker exec ddia-ch08-pg psql -U postgres -d study \
-c "CREATE DATABASE ex_ch08_writeskew;"
docker exec ddia-ch08-pg psql -U postgres -d ex_ch08_writeskew -c "
CREATE TABLE doctors (
name text PRIMARY KEY,
on_call boolean NOT NULL,
shift_id int NOT NULL);
INSERT INTO doctors VALUES ('Alice', true, 1), ('Bob', true, 1);"
Open two terminals — one psql session per doctor:
# terminal 1 — SESSION A (Alice):
docker exec -it ddia-ch08-pg psql -U postgres -d ex_ch08_writeskew
# terminal 2 — SESSION B (Bob):
docker exec -it ddia-ch08-pg psql -U postgres -d ex_ch08_writeskew
Between scenarios, put both doctors back on call from either session:
UPDATE doctors SET on_call = true WHERE shift_id = 1;
Do not read ahead — make each prediction before you run the interleaving. The whole exercise is captured by code/write_skew_doctors.py.
Interleave the two sessions in the order shown (A₁, B₁, A₂, B₂, …). Typing the statements yourself, in this order, is the lesson.
Predict. Both sessions BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ, then each runs SELECT count(*) FROM doctors WHERE on_call = true AND shift_id = 1. A₁ runs first, then B₁. What count does each session see?
-- SESSION A:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true AND shift_id = 1;
-- SESSION B:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true AND shift_id = 1;
Both see 2. Each concludes: "the other doctor is on call, so it's safe for me to leave." Neither has written anything yet.
Predict. A takes Alice off call, B takes Bob off call (different rows), then both COMMIT. Does either UPDATE or COMMIT error? After both commit, what is count(*) WHERE on_call = true?
-- SESSION A:
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;
-- SESSION B:
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
-- then, from either session:
SELECT count(*) FROM doctors WHERE on_call = true AND shift_id = 1;
SELECT name, on_call FROM doctors WHERE shift_id = 1 ORDER BY name;
Zero doctors on call — and not one error. Both UPDATEs succeeded (they hit different rows, so there is no lost-update conflict to detect), both commits succeeded, and the invariant "≥1 on call" is now broken. This is write skew.
Reset first: UPDATE doctors SET on_call = true WHERE shift_id = 1;
Predict. Run the identical interleaving, but begin each transaction with ISOLATION LEVEL SERIALIZABLE. A commits first. What happens when B commits? What is the final on-call count?
-- SESSION A: -- SESSION B:
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) ... ; BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) ... ;
UPDATE doctors SET on_call = false WHERE name = 'Alice';
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT; COMMIT;
A commits; B is aborted with SQLSTATE 40001. Bob's transaction is rolled back — Bob is still on call. Final state: 1 on call, invariant preserved. The HINT says it all: the application is expected to retry B, and on retry B will read count = 1 and correctly decline to leave.
Reset again: UPDATE doctors SET on_call = true WHERE shift_id = 1;
Predict. Back at a weaker level (READ COMMITTED), each session locks the rows it counts with SELECT … FOR UPDATE. A locks first; then B runs the same FOR UPDATE. A takes Alice off call and commits. What does B's SELECT … FOR UPDATE do while A holds the locks — and what rows does it return once it proceeds?
-- SESSION A: -- SESSION B:
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT name FROM doctors
WHERE on_call = true AND shift_id = 1 FOR UPDATE;
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT name FROM doctors
WHERE on_call = true AND shift_id = 1 FOR UPDATE; -- blocks
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT; -- B unblocks here
B blocked until A committed, then re-read and saw only Bob — one doctor on call. Because FOR UPDATE took a lock on the rows the decision depended on, B could not proceed on a stale snapshot; when it did proceed it saw the current truth (1 on call) and must not leave. Final state: 1 on call.
count = 2; both UPDATEs and both COMMITs succeed with no error; final on-call count is 0 — invariant violated.COMMIT fails with ERROR: could not serialize access due to read/write dependencies among transactions (SQLSTATE 40001); final on-call count is 1.SELECT … FOR UPDATE blocks until A commits, then returns only Bob (1 row); final on-call count is 1.Every transaction here makes a decision — "it's safe for me to leave" — and that decision rests on a premise: "at least two doctors are on call." Under snapshot isolation, each transaction reads that premise from its own snapshot, taken at BEGIN, and the premise is true in that snapshot. The catastrophe is that the premise is about rows the transaction only read, and the action it then takes (UPDATE … SET on_call = false) is precisely what makes the other transaction's identical premise false. Snapshot isolation guarantees your reads are consistent with each other; it does not guarantee they are still true when you commit. And Postgres's lost-update protection can't save you either, because that mechanism watches for two transactions writing the same row — here they write disjoint rows (Alice, Bob), so there is no collision to detect. Two transactions, each correct in isolation, combine into an outcome (0 on call) that no serial execution — A-then-B or B-then-A — could ever produce: run them one at a time and the second reads count = 1 and stops.
SERIALIZABLE is the level that forbids exactly this. Postgres implements it as Serializable Snapshot Isolation: it keeps running on snapshots (so readers still don't block writers) but additionally tracks read/write dependencies — "transaction B read a predicate that transaction A then wrote into." When both transactions have such a dependency on each other, they form a cycle: A must come "before" B (A didn't see B's write) and B must come "before" A. No serial order satisfies both, so at commit Postgres identifies one transaction as the pivot and aborts it with 40001 — the exact DETAIL: … Canceled on identification as a pivot you saw. The application's contract with a serializable database is to catch 40001 and retry; on retry the survivor reads the now-true count and behaves. SELECT … FOR UPDATE reaches the same safety a heavier way: by locking the counted rows it turns the read-to-decide into a write lock, so the two transactions can no longer both proceed on the same premise — the second blocks, then re-reads reality.
on_call_count counter), Postgres would have caught it as a lost update and aborted one even at REPEATABLE READ — no serializability needed. And if the invariant were enforceable by a constraint on a single row (e.g. a CHECK or a unique index that the database evaluates atomically), the database would reject the offending write directly and write skew couldn't arise. Write skew appears precisely in the middle: a rule that spans multiple rows, checked by reading them, then enforced by writing different ones. That shape — read a set, decide, write elsewhere — is the signature to watch for whenever you reach for anything below SERIALIZABLE.
shifts(shift_id, min_on_call) row and have each transaction SELECT … FOR UPDATE that single shift row before deciding. Predict whether the anomaly vanishes at REPEATABLE READ now (it does) — you've turned an implicit predicate into an explicit lockable object, the "materializing conflicts" technique from the chapter.bookings(room_id, time_range) and two transactions each checking "no overlapping booking exists" then inserting their own. There's no row to lock (the conflicting row doesn't exist yet) — a true phantom. Watch SERIALIZABLE still catch it via SSI's predicate tracking, and see why FOR UPDATE alone can't.40001 and re-runs the transaction. Confirm that on the second attempt B reads count = 1 and declines to leave — turning the abort into correct behavior, which is how serializable applications are actually written.Sources: DDIA 2e, Ch. 8, §"Write Skew and Phantoms", §"Characterizing write skew", §"Serializable Snapshot Isolation (SSI)" · PostgreSQL 16 manual, §"Transaction Isolation" and §"Serialization Failure Handling".