studyDDIAChapter 8 · Transactions

A Phantom Double-Books a Room, and FOR UPDATE Can't Stop It

Two REPEATABLE READ transactions each check that a meeting room is free — both see 0 overlapping bookings — then each INSERT the same slot and commit. The room ends up double-booked. Adding SELECT … FOR UPDATE, the lost-update fix, changes nothing: there is no row to lock. SERIALIZABLE aborts one with a real 40001; an exclusion constraint rejects one with a real 23P01.


Concept

Two people book the same meeting room at the same time. Each transaction first checks that the room is free — SELECT count(*) FROM bookings for anything overlapping room 1, 10:00–11:00 — and both see 0, because the table starts empty. Reassured, each INSERTs its booking and commits. Under REPEATABLE READ both commits succeed and the room is double-booked: two rows for one slot. This is write skew, and the twist is what fixes it. The classic lost-update fix — SELECT … FOR UPDATE to lock the row you're about to change — does nothing here, because there is no row to lock yet: the conflicting row is a phantom, one that a concurrent transaction is about to INSERT. You'll watch FOR UPDATE fail to help, then watch two real fixes work: SERIALIZABLE aborts one transaction with a genuine 40001, and an exclusion constraint rejects the second INSERT with a genuine 23P01.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 8 — Transactions, §"Phantoms causing write skew" (and §"Materializing conflicts"):

This effect, where a write in one transaction changes the result of a search query in another transaction, is called a phantom. Snapshot isolation avoids phantoms in read-only queries, but in read-write transactions […] phantoms can lead to […] write skew.

The book's Example 8-2 is exactly this: a booking system where two transactions check availability, both find the slot free, and both insert. Because the row they conflict over does not exist at check time, row-level locking (FOR UPDATE) has nothing to grab. Here you reproduce that on real PostgreSQL and close it two ways the book names.

Prerequisites

The surprise is that a lock can't protect a row that isn't there yet. These help; each line notes where it shows up.

  1. Write skew — two transactions read an overlapping set, each decides based on what it read, and each writes; the writes don't touch the same row, but together they break an invariant. It shows up as both sessions reading "0 bookings" and each inserting one.
  2. Phantoms — a write in one transaction changes the result set of a search predicate in another. It shows up as the booking that "wasn't there" when you counted but exists by the time you commit.
  3. Predicate / index-range locks — to prevent a phantom you must lock the query (all current and future rows matching a predicate), not a row. Plain FOR UPDATE can't; this is why it fails here.
  4. Serializable Snapshot Isolation (SSI) — PostgreSQL's SERIALIZABLE: optimistic, it tracks the read/write dependencies (including the predicate you queried) and aborts a transaction if committing would break serializability. It shows up as the 40001 on the second commit.
  5. Materializing conflicts — if there's no row to lock, create one: a lock row per bookable slot, or a constraint that makes the database refuse the overlap physically. It shows up as the EXCLUDE USING gist constraint raising 23P01.
Where to learn the prerequisites Write skew, phantoms, and the fixes (#1–#3, #5): DDIA §"Write Skew and Phantoms", §"Phantoms causing write skew", §"Materializing conflicts". SSI (#4): DDIA §"Serializable Snapshot Isolation (SSI)"; the PostgreSQL manual §13.2.3 "Serializable Isolation Level" and Ports & Grittner, "Serializable Snapshot Isolation in PostgreSQL" (VLDB 2012). If only one is new, make it #3 — that a row lock cannot lock the absence of a row is the entire reason FOR UPDATE fails and predicate-level protection is needed.
Environment these transcripts came from Deterministic — the interleaving is forced, so the outcomes 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. A single shared server; this exercise creates and works in its own database ex_ch08_phantom, table bookings, starting empty (no overlapping booking).

Mental model

Same interleaving every time — A checks, B checks, A inserts, B inserts, A commits, B commits — run under four regimes. Only the protection differs.

RegimeWhat it locks / checksStops the double-booking?
REPEATABLE READ, plain checknothing — each sees its own snapshotNo — both commit
REPEATABLE READ + SELECT … FOR UPDATEexisting rows in the result set — and there are noneNo — locks nothing, both commit
SERIALIZABLE (SSI)the read/write dependency between the two transactions, predicate includedYes — aborts one with 40001
EXCLUDE USING gist constrainta physical index entry standing in for the predicateYes — rejects one INSERT with 23P01

The pivot is the second row. FOR UPDATE is the textbook cure for lost updates — it locks the row you read so a concurrent transaction can't overwrite it. But a lock is a claim on a row that exists. The conflict here is over a row that doesn't exist yet: each transaction's INSERT is the phantom that would have changed the other's count(*). You cannot lock a row into existence to prevent its own creation. Only something that reasons about the predicate you searched — SSI's dependency tracking, or a constraint indexed over the slot — can see the collision. The capture harness is code/phantom_write_skew.py; the steps below you run by hand in two psql sessions.

Setup

The shared server (already running for this chapter):

docker run -d --name ddia-ch08-pg -p 5432:5432 \
  -e POSTGRES_HOST_AUTH_METHOD=trust postgres:16

Create your own database and the empty bookings table:

docker exec -i ddia-ch08-pg psql -U postgres -c "CREATE DATABASE ex_ch08_phantom"

docker exec -i ddia-ch08-pg psql -U postgres -d ex_ch08_phantom -c "
CREATE TABLE bookings (
  id serial PRIMARY KEY,
  room_id int NOT NULL,
  start_hour int NOT NULL,
  end_hour int NOT NULL);"

Now open two interactive sessions — Session A and Session B — in two terminals:

# Session A
docker exec -it ddia-ch08-pg psql -U postgres -d ex_ch08_phantom

# Session B (second terminal)
docker exec -it ddia-ch08-pg psql -U postgres -d ex_ch08_phantom

Do not read the transcripts below yet — make each prediction first. Between steps, empty the table so each starts clean: TRUNCATE bookings;.


Step 1 · REPEATABLE READ: both check, both book

Predict. Both sessions open a REPEATABLE READ transaction and count overlapping bookings for room 1, 10:00–11:00 (the table is empty). Each then inserts its booking; A commits, then B commits. How many bookings exist for that slot at the end — 1 or 2?

-- Session A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM bookings WHERE room_id=1 AND start_hour<11 AND end_hour>10;

-- Session B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM bookings WHERE room_id=1 AND start_hour<11 AND end_hour>10;

-- Session A
INSERT INTO bookings (room_id, start_hour, end_hour) VALUES (1, 10, 11);
-- Session B
INSERT INTO bookings (room_id, start_hour, end_hour) VALUES (1, 10, 11);

-- Session A
COMMIT;
-- Session B
COMMIT;

-- either session
SELECT count(*) FROM bookings WHERE room_id=1 AND start_hour<11 AND end_hour>10;
How many bookings exist at the end — 1 or 2?
=== isolation level: REPEATABLE READ === Session A check: overlapping bookings for room 1, 10-11 -> 0 Session B check: overlapping bookings for room 1, 10-11 -> 0 Session A INSERT booking (room 1, 10-11) Session B INSERT booking (room 1, 10-11) Session A COMMIT -> OK Session B COMMIT -> OK result: 2 booking(s) exist for room 1, 10-11

Two bookings — double-booked. Each transaction read its own snapshot, saw an empty slot, and inserted. The two inserts touch different rows, so neither blocks the other and both commit cleanly. REPEATABLE READ (snapshot isolation) prevented every read anomaly and still let the invariant break.

Step 2 · add SELECT … FOR UPDATE: still double-booked

Predict. Same interleaving, but each check now takes a write lock on the rows it finds: SELECT id … FOR UPDATE. This is the standard fix for lost updates. Does it stop the double-booking this time?

-- Session A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT id FROM bookings WHERE room_id=1 AND start_hour<11 AND end_hour>10 FOR UPDATE;

-- Session B
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT id FROM bookings WHERE room_id=1 AND start_hour<11 AND end_hour>10 FOR UPDATE;

-- Session A
INSERT INTO bookings (room_id, start_hour, end_hour) VALUES (1, 10, 11);
-- Session B
INSERT INTO bookings (room_id, start_hour, end_hour) VALUES (1, 10, 11);

-- Session A
COMMIT;
-- Session B
COMMIT;
Does locking the (empty) result set stop the double-booking?
=== isolation level: REPEATABLE READ (SELECT ... FOR UPDATE) === Session A check: overlapping bookings for room 1, 10-11 -> 0 Session B check: overlapping bookings for room 1, 10-11 -> 0 Session A INSERT booking (room 1, 10-11) Session B INSERT booking (room 1, 10-11) Session A COMMIT -> OK Session B COMMIT -> OK result: 2 booking(s) exist for room 1, 10-11

Still two. FOR UPDATE locked every row the query matched — and it matched zero rows, so it locked nothing. B's SELECT … FOR UPDATE never even sees A's uncommitted row (it's not in B's snapshot, and it isn't committed), and A's insert doesn't wait on B's lock because B holds no lock. The lost-update cure is useless against a phantom: there is no existing row for either session to lock.

Step 3 · SERIALIZABLE: one transaction is aborted

Predict. Same interleaving again, now both transactions at SERIALIZABLE. A commits first. What happens to B's COMMIT?

-- Session A
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM bookings WHERE room_id=1 AND start_hour<11 AND end_hour>10;

-- Session B
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM bookings WHERE room_id=1 AND start_hour<11 AND end_hour>10;

-- Session A
INSERT INTO bookings (room_id, start_hour, end_hour) VALUES (1, 10, 11);
-- Session B
INSERT INTO bookings (room_id, start_hour, end_hour) VALUES (1, 10, 11);

-- Session A
COMMIT;
-- Session B
COMMIT;
What happens to B's COMMIT?
=== isolation level: SERIALIZABLE === Session A check: overlapping bookings for room 1, 10-11 -> 0 Session B check: overlapping bookings for room 1, 10-11 -> 0 Session A INSERT booking (room 1, 10-11) Session B INSERT booking (room 1, 10-11) Session A COMMIT -> OK Session B COMMIT -> ERROR SQLSTATE 40001 could not serialize access due to read/write dependencies among transactions result: 1 booking(s) exist for room 1, 10-11

In B's psql session the COMMIT returns the real error verbatim:

ERROR: could not serialize access due to read/write dependencies among transactions DETAIL: Reason code: Canceled on identification as a pivot, during commit attempt. HINT: The transaction might succeed if retried.

One booking — B was aborted. SSI tracked that B read a predicate (bookings overlapping the slot) that A then wrote into, and vice versa — a dangerous read/write dependency structure. On B's commit it detected B as the "pivot" and canceled it with 40001. The application is expected to retry B, which on a second run sees A's booking and declines. One booking survives; the invariant holds.

Step 4 · materialize the conflict: an exclusion constraint

Predict. Drop back to the default isolation but add a constraint that forbids two overlapping bookings of the same room physically: EXCLUDE USING gist (room_id WITH =, int4range(start_hour, end_hour) WITH &&) (needs the btree_gist extension). A inserts and commits; then B inserts. What does B's INSERT do?

docker exec -i ddia-ch08-pg psql -U postgres -d ex_ch08_phantom -c "
CREATE EXTENSION IF NOT EXISTS btree_gist;
TRUNCATE bookings;
ALTER TABLE bookings ADD CONSTRAINT no_double_booking
  EXCLUDE USING gist (room_id WITH =, int4range(start_hour, end_hour) WITH &&);"
-- Session A
BEGIN;
INSERT INTO bookings (room_id, start_hour, end_hour) VALUES (1, 10, 11);
COMMIT;

-- Session B
BEGIN;
INSERT INTO bookings (room_id, start_hour, end_hour) VALUES (1, 10, 11);   -- rejected
What does B's INSERT do?
=== EXCLUDE USING gist constraint (READ COMMITTED) === Session A check: overlapping bookings for room 1, 10-11 -> 0 Session B check: overlapping bookings for room 1, 10-11 -> 0 Session A INSERT booking (room 1, 10-11) Session A COMMIT -> OK Session B INSERT -> ERROR SQLSTATE 23P01 conflicting key value violates exclusion constraint "no_double_booking" DETAIL: Key (room_id, int4range(start_hour, end_hour))=(1, [10,11)) conflicts with existing key (room_id, int4range(start_hour, end_hour))=(1, [10,11)). result: 1 booking(s) exist for room 1, 10-11

In B's psql session:

ERROR: conflicting key value violates exclusion constraint "no_double_booking" DETAIL: Key (room_id, int4range(start_hour, end_hour))=(1, [10,11)) conflicts with existing key (room_id, int4range(start_hour, end_hour))=(1, [10,11)).

One booking — B's INSERT was refused. The exclusion constraint is a real index entry that is the predicate "no two overlapping bookings of a room." If B's insert had raced A's (both uncommitted), B would block on A's index entry and then fail on A's commit. The phantom now has a physical home the database can lock, so the collision is caught the moment the row is written — no isolation level required.


What you should see

Why

Snapshot isolation (PostgreSQL's REPEATABLE READ) gives each transaction a frozen view of the database as of its first statement. That kills every read anomaly — dirty reads, non-repeatable reads, read skew — because a transaction never sees another's uncommitted or newer data. But correctness here isn't about what you read; it's about a rule spanning rows that don't exist yet: "at most one booking overlaps this slot." Each transaction reads the slot as empty (true, in its snapshot), decides to book, and inserts. The two inserts are different rows, so from the storage engine's view nothing conflicts — no row is written twice, no lock is contended — and both commit. That's write skew: individually legal writes that jointly break an invariant, enabled by a phantom, the row each transaction's insert adds to the other's search result after that other already looked.

SELECT … FOR UPDATE is built to stop lost updates: it takes a write lock on the rows a query returns, so a concurrent transaction that wants to modify the same row must wait. Its power is a lock on a row that exists. Against a phantom that power is empty — the query returns no rows, so there is nothing to lock, and each transaction's own future INSERT is invisible to the other's snapshot and uncommitted besides. Locking rows can never lock the absence of a row. The only things that can are those that reason about the predicate rather than the row: PostgreSQL's SERIALIZABLE (SSI) tracks that B read a range A then wrote, and that the two form a cycle that no serial order can reproduce, so it aborts one with 40001 (SSI is optimistic — it lets both run and cancels a "pivot" at commit, expecting the app to retry). Materializing the conflict does the same job pessimistically: an EXCLUDE USING gist constraint builds a physical index over (room, hour-range) that is the predicate, giving the phantom a concrete entry the database can detect and reject with 23P01. Both work because both protect the query, not the row.

The boundary — phantoms only bite when the invariant spans rows that don't exist yet If your conflict is over a row that already exists — two people editing the same account balance — then SELECT … FOR UPDATE is exactly right and cheaper than serializable: there is a real row to lock, so lock it. The moment the invariant is a predicate over a set ("no overlapping booking," "username not taken," "at least one doctor on call"), the conflicting row may not exist at check time, and only predicate-level protection — SSI, a materialized lock row, or a constraint indexed over the predicate — closes the gap. Reach for row locks when the thing to protect is a row; reach for predicate protection when the thing to protect is a query.

Go deeper

Sources: DDIA 2e, Ch. 8, §"Write Skew and Phantoms", §"Phantoms causing write skew", §"Materializing conflicts", §"Serializable Snapshot Isolation (SSI)" · PostgreSQL manual §13.2.3 · Ports & Grittner, "Serializable Snapshot Isolation in PostgreSQL" (VLDB 2012).