studyDDIAChapter 10 · Consistency and Consensus

SERIALIZABLE Still Returns a Stale Read — It Is Not LINEARIZABLE

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.


Concept

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.

Provenance

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.

Prerequisites

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.

  1. Serializability = equivalent to some serial order — the outcome must match running the transactions one at a time in some sequence, but the engine is free to pick which sequence. It shows up as T2 being ordered before T1 even though T1 committed first in wall-clock time.
  2. Linearizability = real-time recency — every read must return the value of the most recent completed write, as ordered by the wall clock; once a write commits, no later read may return an older value. It shows up as the guarantee T2's second read would need — and doesn't get.
  3. Snapshot isolation / MVCC — a transaction reads from a consistent snapshot fixed at its first query; PostgreSQL's SERIALIZABLE (SSI) builds on top of this snapshot. It shows up as T2's second read still seeing x = 0 because its snapshot predates T1's commit.
  4. The two guarantees are independent — a system can be serializable without being linearizable (this exercise), or linearizable without being serializable (a single-object register with no multi-object transactions). It shows up as T2 committing cleanly and returning stale data at the same time.
Where to learn the prerequisites Serializability vs. linearizability (#1, #2, #4): DDIA §"Linearizability" and §"Linearizability Versus Serializability"; Peter Bailis, "Linearizability versus Serializability" (bailis.org/blog) — the canonical short explainer of why one does not imply the other. Snapshot isolation / SSI (#3): DDIA §"Serializable Snapshot Isolation (SSI)"; the PostgreSQL manual, ch. 13 "Concurrency Control" → "Serializable Isolation Level". If only one is new, make it #1 — some serial order (chosen by the engine), not the real-time order, is the entire reason a stale read stays legal.
Environment these values came from The interleaving is fixed, not a race, so the result is 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), two sessions against one database; deterministic. The reader-facing steps use two manual 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.

Mental model

Two guarantees, often conflated, constrain different things. One register, two transactions — T2 reads twice, T1 writes once in between.

GuaranteeWhat it constrainsThe ruleDoes 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.

Setup

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.


Step 1 · T2 takes its snapshot, then T1 commits underneath it (the surprise)

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;
Does T2's second read return 0 or 1?
BEGIN x --- 0 (1 row) x --- 0 (1 row)

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.

Step 2 · T2 commits with no serialization error

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;
Does T2's COMMIT abort with 40001 or succeed?
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.

Step 3 · a fresh transaction sees the write (it was never lost)

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;
Does the fresh transaction read 0 or 1?
BEGIN x --- 1 (1 row) 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.


What you should see

Why

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.

The boundary — the staleness lives in the long-lived snapshot, not in SERIALIZABLE itself Read the same register in a fresh transaction (Step 3), or force a real-time-current read with 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)?

Go deeper

Sources: DDIA 2e, Ch. 10, §"Linearizability", §"Linearizability Versus Serializability", §"Serializable Snapshot Isolation (SSI)" · Peter Bailis, "Linearizability versus Serializability".