A register holds x = 0. Transaction T2 opens under SERIALIZABLE and reads 0, fixing its snapshot. T1 then writes x = 1 and commits. T2's second read still returns 0 — and T2 commits with no serialization error. The execution is perfectly serializable (equivalent to T2 entirely before T1); the stale read is legal. Recency in real time is linearizability, the guarantee serializability lacks.
A single register reg holds x = 0. Transaction T2 opens under SERIALIZABLE and reads x — it sees 0, fixing its snapshot. A separate transaction T1 then writes x = 1 and commits. Now, back in the same T2, you read x a second time. The intuition "SERIALIZABLE means I always see the latest committed value" predicts 1. It returns 0 — the stale value — and T2 then COMMITs with no serialization error (no SQLSTATE 40001). Nothing was violated: the execution is perfectly serializable, equivalent to running T2 entirely before T1, so the stale read is legal in that serial order. What serializability does not give you is recency in real time — that stronger property is linearizability, and PostgreSQL's MVCC reads don't provide it. A fresh transaction after T2 commits reads 1: T1's write was never lost, just invisible to T2's older snapshot. You will reproduce this on a real PostgreSQL 16 server, in two hand-typed psql sessions.
Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 10 — Consistency and Consensus, §"Linearizability Versus Serializability". The book's claim, tightly paraphrased:
Serializability and linearizability are different guarantees. A database can be serializable without being linearizable — serializability permits stale reads — and the combination of both is called strict serializability.
Serializability constrains the outcome to match some serial order of the transactions; linearizability constrains when each operation takes effect relative to the wall clock. The two are independent guarantees. Here you watch a SERIALIZABLE transaction take a serial order that puts T2 before T1 — and so read a value that is already stale in real time, without any error.
The surprise turns on a single distinction: some serial order need not be the real-time order. These help; each line notes where it shows up.
x = 0 because its snapshot predates T1's commit.psql sessions against the same server; code/serializable_not_linearizable.py is the capture harness that drives the identical two-session interleaving programmatically and prints the values quoted below.
Two guarantees, often conflated, constrain different things. One register, two transactions — T2 reads twice, T1 writes once in between.
| Guarantee | What it constrains | The rule | Does this exercise satisfy it? |
|---|---|---|---|
| Serializability | the outcome | the result must equal some serial order of the transactions — the engine picks which | Yes — the order is T2 (reads 0, 0) then T1 (writes 1); a valid serial schedule |
| Linearizability | when each op takes effect | each op appears to take effect atomically at some point between its call and return, respecting real-time order; a read after a committed write must see it | No — T1 committed x=1 in real time before T2's second read, yet that read returns 0 |
A database can have one without the other. PostgreSQL SERIALIZABLE gives the first (serializable outcomes via SSI) but not the second (MVCC reads answer from a snapshot, not the real-time latest). "Strict serializability" is both at once — what Spanner provides via TrueTime. The whole interleaving is one script, code/serializable_not_linearizable.py; the Steps you run by hand in two psql sessions.
The server is already running (a shared postgres:16 container). Give yourself your own database and a single register seeded to 0, then open two psql sessions side by side — call them T2 (the reader, opened first) and T1 (the writer).
# your own database + seed
psql -h localhost -U postgres -d postgres \
-c "DROP DATABASE IF EXISTS ex_ch10_serlin; CREATE DATABASE ex_ch10_serlin;"
psql -h localhost -U postgres -d ex_ch10_serlin <<'SQL'
CREATE TABLE reg (id int PRIMARY KEY, x int);
INSERT INTO reg VALUES (1, 0);
SQL
# two sessions, one per terminal:
# T2 (reader): psql -h localhost -U postgres -d ex_ch10_serlin
# T1 (writer): psql -h localhost -U postgres -d ex_ch10_serlin
The capture harness code/serializable_not_linearizable.py does all of this itself (drop + recreate + seed + the interleaving) and is what produced the values below:
uv run --python 3.12 --with "psycopg[binary]" python3 code/serializable_not_linearizable.py
Do not read ahead — make each prediction first.
Predict. In T2, open a SERIALIZABLE transaction and read x (this returns 0 and fixes the snapshot). Then, in T1, write x = 1 and commit. Back in T2 (still the same transaction), read x a second time. Does the second read return 0 (T2's snapshot) or 1 (T1's committed write)?
-- T2: open the transaction, first read fixes the snapshot
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT x FROM reg WHERE id = 1;
-- T1: write x = 1 and commit (autocommit: one statement is one transaction)
UPDATE reg SET x = 1 WHERE id = 1;
-- T2: same transaction, read x again
SELECT x FROM reg WHERE id = 1;
Both reads return 0 — the second is stale. T1's x = 1 committed in real time between T2's two reads, yet T2's second read still returns 0. T2's snapshot was fixed at its first query, before T1 committed, and every read in the transaction is answered from it. This is exactly the recency guarantee linearizability would give and serializability does not.
Predict. T2 read a value (0) that a concurrent transaction has already overwritten and committed. When T2 now COMMITs, does PostgreSQL abort it with a serialization failure (SQLSTATE 40001, "could not serialize access"), or does it commit cleanly?
-- T2: commit the transaction
COMMIT;
Clean commit — no 40001. SSI only aborts a transaction when the committed outcome could not correspond to any serial order (a dangerous read/write dependency cycle). Here there is no cycle: ordering T2 entirely before T1 is a perfectly valid serial schedule that produces exactly what T2 saw (0, then 0). The stale read is not an anomaly to be caught — it is consistent with a real serial order, just not with the real-time one.
Predict. T2 has committed. Open a brand-new transaction and read x. Does it return T2's stale 0, or T1's committed 1?
-- a fresh session/transaction, entirely after T2 committed
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT x FROM reg WHERE id = 1;
COMMIT;
1 — T1's write was there all along. The staleness was never data loss; it was purely a property of T2's old snapshot. Any transaction whose snapshot is taken after T1's commit sees 1. The staleness is specific to a long-lived snapshot that started before the write committed.
x = 1, then T2 read 2 = 0 — a stale read under SERIALIZABLE.COMMIT succeeds, no SQLSTATE 40001 — the execution is serializable (equivalent to T2 before T1).STALE READ under SERIALIZABLE / succeeded (no 40001) / T3 read = 1.Serializability and linearizability answer two different questions, and the exercise pries them apart. Serializability asks: is the result equivalent to running these transactions one at a time in some order? Linearizability asks: does every operation appear to take effect at a single instant, consistent with the real-time order in which the operations actually happened? The first is about the existence of a serial order; the second is about which order — the wall-clock one.
T2's second read is stale because its snapshot was taken at its first query, before T1 committed. Under MVCC, that snapshot pins the exact set of row versions T2 can see, and T1's later commit creates a newer version that is simply not in T2's visible set. So T2 reads 0 both times. Now ask whether this violates serializability: is there a serial order of {T2, T1} that produces this outcome? Yes — T2 entirely before T1. In that order T2 reads 0, reads 0, and commits; then T1 writes 1. Every value T2 saw is consistent with that schedule, so SSI has nothing to abort, and COMMIT returns cleanly. The read is stale only against the real-time order (T1 committed first), and serializability never promised to respect real-time order.
To also be linearizable, T2's second read would have to reflect T1's already-committed write — a strictly stronger, real-time property. Linearizability would forbid the serial order "T2 before T1" here, because T1's commit precedes T2's second read in real time, so any linearizable history must order T1 before that read and the read must return 1. PostgreSQL's MVCC reads deliberately don't provide this for a transaction's snapshot: the whole point of a snapshot is to give a stable view, which means ignoring writes that commit after it opens. You get serializable outcomes without paying for real-time recency.
SELECT x FROM reg WHERE id = 1 FOR UPDATE (which takes a row lock and reads the latest committed version, blocking on or conflicting with concurrent writers), and you see 1. The gap only opens when a transaction holds a snapshot taken before a write and then reads after that write commits. A single-object store that answers every read from the latest committed value is linearizable for that object — it just isn't doing multi-statement snapshot reads. So place your own system by asking: does a read answer from a point-in-time snapshot (serializable, possibly stale) or from the real-time-latest state (linearizable, always current)?
40001. Give T2 a write that depends on its stale read (e.g. both T1 and T2 read then write the same rows to form a write-skew cycle) and watch SSI abort one with SQLSTATE 40001 — the contrast with this exercise's clean commit shows exactly which stale-read consequences serializability forbids (dangerous cycles) versus permits (a pure stale read).ISOLATION LEVEL REPEATABLE READ instead of SERIALIZABLE. Predict whether the second read changes (it won't — both levels sit on the same MVCC snapshot), and confirm that the stale read is a snapshot property, not a SERIALIZABLE-specific one.Sources: DDIA 2e, Ch. 10, §"Linearizability", §"Linearizability Versus Serializability", §"Serializable Snapshot Isolation (SSI)" · Peter Bailis, "Linearizability versus Serializability".