Demo 4: Serializable — SSI Catches the Write-Skew Cycle
Isolation level: Serializable
Source: sql/04_serializable.sql
Interactive version: /start-4
The fix
PostgreSQL implements SERIALIZABLE via Serializable Snapshot Isolation (SSI). Like Repeatable Read, each transaction sees a snapshot — but SSI also tracks predicate reads (which rows your WHERE clauses depended on). If at commit time it detects a read/write dependency pattern that couldn’t have happened in any serial order, it aborts one transaction with SQLSTATE 40001.
We replay the exact doctors-on-call scenario from demo 3. The first commit succeeds; the second hits a serialization failure and must retry. The invariant is preserved.
Setup
./scripts/reset.shSession 1 = Alice, Session 2 = Bob.
Walkthrough
Session 1 — open SERIALIZABLE and read
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call;
-- EXPECT: 2Session 2 — open SERIALIZABLE and read
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call;
-- EXPECT: 2Session 1 (Alice) — go off call and commit
SELECT count(*) FROM doctors WHERE on_call AND id <> 1;
-- EXPECT: 1
UPDATE doctors SET on_call = false WHERE id = 1;
COMMIT; -- succeeds (first writer wins)Session 2 (Bob) — try the same
SELECT count(*) FROM doctors WHERE on_call AND id <> 2;
-- EXPECT: 1
UPDATE doctors SET on_call = false WHERE id = 2;
COMMIT;
-- EXPECT: ERROR could not serialize access due to read/write
-- dependencies among transactions
-- SQLSTATE 40001
-- HINT: The transaction might succeed if retried.The failure may surface on the UPDATE or on the COMMIT, depending on what SSI’s dependency tracker decides — either way the transaction must roll back and retry.
Session 2 — retry; now it sees reality
ROLLBACK; -- safe even if already aborted
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call;
-- EXPECT: 1 (Alice already off call)
COMMIT;The retry sees only one doctor on call, so the application logic correctly declines to take Bob off call.
Either session — verify
SELECT count(*) AS doctors_on_call FROM doctors WHERE on_call;
-- EXPECT: 1 <- Bob is still on call. Invariant preserved.Takeaway
Serializable makes your application logic correct as if every transaction ran one at a time, with no extra locks in your code. The price: your code must handle SQLSTATE 40001 by retrying. It’s opt-in per transaction, so many apps use it only for the handful of paths with non-trivial invariants.
For a lock-based alternative with no retries, see SELECT ... FOR UPDATE.