studyPostgreSQL › Transaction Isolation Levels

Transaction Isolation Levels

Same schema, same statements, different outcomes — walking a real Postgres up the isolation ladder until an anomaly only SERIALIZABLE can catch.


Concept

Two transactions running at the same time can see each other's changes to varying degrees, controlled entirely by the isolation level. This exercise walks up the ladder (READ COMMITTEDREPEATABLE READSERIALIZABLE) and, for each step, produces the exact anomaly the next level exists to prevent, ending with a case — write skew — that only SERIALIZABLE catches.

Provenance

PostgreSQL 16 documentation, Chapter 13 “Concurrency Control”, §13.2 “Transaction Isolation”, and its three subsections. The claim under test, in one line: the same interleaving of the same statements is legal at REPEATABLE READ and illegal at SERIALIZABLE.

§13.2 intro — “In PostgreSQL, you can request any of the four standard transaction isolation levels, but internally only three distinct isolation levels are implemented, i.e., PostgreSQL's Read Uncommitted mode behaves like Read Committed.” (Part 1: there are no dirty reads to produce.)

§13.2.1 Read Committed — “In effect, a SELECT query sees a snapshot of the database as of the instant the query begins to run.” (Part 2, first half: the snapshot is per-statement, so two identical SELECTs in one transaction can disagree.)

§13.2.2 Repeatable Read — “a query in a repeatable read transaction sees a snapshot as of the start of the first non-transaction-control statement in the transaction, not as of the start of the current statement within the transaction.” And: a repeatable read transaction “cannot modify or lock rows changed by other transactions after the repeatable read transaction began”, so it “will be rolled back with the message” ERROR: could not serialize access due to concurrent update. (Part 2, second half, and the same-row edge case.)

§13.2.3 Serializable — “This level emulates serial transaction execution for all committed transactions; as if transactions had been executed one after another, serially, rather than concurrently… this isolation level works exactly the same as Repeatable Read except that it also monitors for conditions which could make execution of a concurrent set of serializable transactions behave in a manner inconsistent with all possible serial (one at a time) executions of those transactions.” (Part 3.)

The doctors-on-call scenario in Part 3 is not the docs' own example — §13.2.3 uses a mytab(class, value) table where each transaction sums one class and inserts into the other. The doctors framing comes from Kleppmann, Designing Data-Intensive Applications, 2nd ed., Ch. 8. Both are the same shape.

Prerequisites

  1. MVCC snapshot visibility — carries most of Parts 1 and 2. A row version is visible to you if the transaction that created it committed before your snapshot was taken and no committed-before-your-snapshot transaction deleted it. “Isolation level” in Postgres is almost entirely a question of when the snapshot is taken (per statement vs. per transaction).
  2. Serializable Snapshot Isolation (SSI) — carries Part 3. Postgres's SERIALIZABLE is not two-phase locking; it runs snapshot isolation and additionally tracks read/write dependencies between concurrent transactions, aborting the transaction it identifies as the “pivot” of a dependency cycle no serial order could have produced. This is why the failure surfaces as an error you must retry, not as a block.
  3. Write skew — the anomaly Part 3 produces. Two transactions read an overlapping set of rows, then each writes a different row from that set. Neither wrote anything the other read and then re-read, so no per-row check can see the conflict.
Where to learn the prerequisites MVCC (#1): the PostgreSQL manual, §13.4 “Data Consistency Checks at the Application Level”, plus the “Snapshots” chapter of PostgreSQL 14 Internals. SSI (#2): Ports & Grittner, “Serializable Snapshot Isolation in PostgreSQL” (VLDB 2012) — §3 is enough. Write skew (#3): Wikipedia, “Snapshot isolation § Write skew”. If only one is new, make it #2 — SSI is the whole difference between the two halves of Part 3.
Environment these transcripts came from

Mental model: which isolation level do you actually need?

LevelWhat it actually doesReach for it when…
READ COMMITTED
(Postgres default)
Every statement sees whatever's currently committed. No snapshot spans the whole transaction — two SELECTs five seconds apart in the same transaction can see different data if something else committed in between. Most ordinary request handling. Cheapest option, fewest surprises to code around — though the most surprises to reason about, since "the same query might return different things" is easy to forget.
REPEATABLE READ A single snapshot is taken at the transaction's first statement and held for its entire duration. Postgres also detects and rejects any attempt to UPDATE/DELETE a row that a concurrent transaction already committed a change to — a serialization error on that statement, not a silent overwrite. Anything needing a consistent multi-statement view: a report/export that must reflect one instant despite running several queries, a total computed across tables that mustn't mix pre/post-update state. Does not protect an invariant spanning two different rows — see write skew below.
SERIALIZABLE Everything above, plus: Postgres tracks read/write dependencies between concurrent transactions and aborts one side of any cycle that couldn't have arisen from some one-at-a-time execution order — the actual definition of serializability. A workflow that reads a fact, then writes elsewhere based on it, where the two transactions never share a single row for Postgres to lock. Room-booking (two users each check "is this slot free?", both insert), inventory checks, the doctors-on-call case below. Costs more (retry logic on failure) — only worth it when the invariant can't be pinned to one lockable row.
Before reaching for SERIALIZABLE If the conflicting rows are known upfront (e.g. two specific bank accounts), SELECT ... FOR UPDATE locks exactly those rows and avoids retry logic entirely. For booking systems specifically, a UNIQUE (or EXCLUDE, for overlapping ranges) constraint on (room_id, slot) is usually the more robust fix — it turns the race into a database-enforced uniqueness violation regardless of isolation level. SERIALIZABLE earns its cost when no single row or constraint captures the invariant, which is exactly the doctors case: "at least one doctor on call" isn't expressible as a uniqueness constraint on one row.

Setup

Two terminals, side by side. Everything below assumes you have Docker.

docker run --name pg-iso --rm -e POSTGRES_PASSWORD=postgres -d postgres:16

Terminal A:

docker exec -it pg-iso psql -U postgres

Terminal B (a second, independent connection):

docker exec -it pg-iso psql -U postgres

In either session, create the schema once:

CREATE TABLE accounts (id int PRIMARY KEY, balance int NOT NULL);
INSERT INTO accounts VALUES (1, 500), (2, 500);

CREATE TABLE doctors (name text PRIMARY KEY, is_on_call boolean NOT NULL);
INSERT INTO doctors VALUES ('alice', true), ('bob', true);

From here, "A" and "B" refer to the two psql sessions. Watch for the prompt change from postgres=# to postgres=*# — that * means you're inside an open transaction.


Part 1 — dirty reads (there aren't any)

What this part tests: whether one transaction can see another's uncommitted writes at all. Postgres's answer is always no, at every isolation level — there's no real READ UNCOMMITTED in Postgres; it's silently upgraded to READ COMMITTED. This is the floor every level builds on.

Step 1 · A writes without committing
Session A
BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1;
BEGIN UPDATE 1

Don't commit yet.

What does Session B see if it queries accounts right now?
Session B
SELECT balance FROM accounts WHERE id = 1;
balance --------- 500 (1 row)

500, not 400 — A's write is invisible to B until committed. Note B isn't blocked either: readers never wait for writers.

Step 2 · A commits
Session A
COMMIT;
What does Session B see now?
Session B
SELECT balance FROM accounts WHERE id = 1;
balance --------- 400 (1 row)

Part 2 — non-repeatable reads

What this part tests: whether a committed write from another transaction can change the answer to a query you already ran, within the same still-open transaction. Under READ COMMITTED it can (each statement re-checks "currently committed"); under REPEATABLE READ it can't (one frozen snapshot for the whole transaction).

Reset first — Session A: UPDATE accounts SET balance = 500 WHERE id = 1;

Step 3 · READ COMMITTED
Session B
BEGIN; SELECT balance FROM accounts WHERE id = 1;
BEGIN balance --------- 500 (1 row)

Leave this transaction open.

Session A — a full, independent, already-committed transaction:
BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT;
BEGIN UPDATE 1 COMMIT

The explicit BEGIN matters only cosmetically: without it psql's autocommit already commits the UPDATE, and the bare COMMIT then prints WARNING:  there is no transaction in progress.

Session B runs the exact same select again, inside the same still-open transaction. Same result, or different?
Session B
SELECT balance FROM accounts WHERE id = 1;
balance --------- 400 (1 row)

400. Two identical reads, same transaction, different answers — a non-repeatable read.

Session B COMMIT;

Now the same scenario at REPEATABLE READ. Reset — Session A: UPDATE accounts SET balance = 500 WHERE id = 1;

Step 4 · REPEATABLE READ
Session B
BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT balance FROM accounts WHERE id = 1;
BEGIN balance --------- 500 (1 row)
Session A
BEGIN; UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT;
BEGIN UPDATE 1 COMMIT
Same query again in Session B's open transaction — still 500, or 400 now?
Session B
SELECT balance FROM accounts WHERE id = 1;
balance --------- 500 (1 row)

Still 500 — B's snapshot was frozen at its first statement; A's commit is invisible to it no matter how many times B looks.

Session B COMMIT; then the same SELECT again, in a fresh transaction:

COMMIT balance --------- 400 (1 row)
Important edge case

REPEATABLE READ isn't purely passive about concurrent writes. If two REPEATABLE READ transactions try to update the same row, the second one fails outright rather than silently overwriting the first.

Reset to 500, then have A and B both run BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT balance FROM accounts WHERE id = 1; (both see 500), then A runs UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT;.

Now B tries to update the same row. What happens to B's UPDATE?
Session B
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
ERROR: could not serialize access due to concurrent update

B's UPDATE itself errors immediately, before B even attempts to commit. With \set VERBOSITY verbose psql shows where it came from:

ERROR: 40001: could not serialize access due to concurrent update LOCATION: ExecUpdate, nodeModifyTable.c:2418

Roll B back before continuing. Keep this in mind for Part 3: REPEATABLE READ does catch same-row conflicts. It just has no way to catch a conflict that spans two different rows — which is exactly what write skew is.


Part 3 — write skew (the one REPEATABLE READ misses)

What this part tests: an invariant spanning two different rows, where neither transaction ever writes the row the other one reads or writes. The same-row protection above can't help — there's no single row conflict for Postgres to detect. This is the same shape as a real double-booking bug: two users each check "is this room booked in this slot?" (reading a different, nonexistent row each time), both see "no," and both proceed to insert their own booking row — no row was ever contested, yet the outcome is wrong.

The invariant here: at least one doctor must always be on call. Confirm the reset state first with SELECT * FROM doctors ORDER BY name;:

name | is_on_call -------+------------ alice | t bob | t (2 rows)
Step 5 · REPEATABLE READ
Session A
BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM doctors WHERE is_on_call;
BEGIN count ------- 2 (1 row)
Session B
BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM doctors WHERE is_on_call;
BEGIN count ------- 2 (1 row)

Both transactions have now seen "2 on call" and, per the invariant, each independently believes it's safe to go off call.

Session A
UPDATE doctors SET is_on_call = false WHERE name = 'alice'; COMMIT;
UPDATE 1 COMMIT
Does Session B's analogous update succeed, fail, or block? (Recall the same-row behavior above — does it apply here?)
Session B
UPDATE doctors SET is_on_call = false WHERE name = 'bob'; COMMIT;
UPDATE 1 COMMIT

It succeeds — unlike the same-row case, because this is a different row, so there's nothing for REPEATABLE READ's conflict detection to catch.

SELECT * FROM doctors ORDER BY name;
name | is_on_call -------+------------ alice | f bob | f (2 rows)

Zero doctors on call. Invariant broken, no error raised anywhere.

Reset: UPDATE doctors SET is_on_call = true; (prints UPDATE 2). Now repeat Step 5 exactly, with BEGIN ISOLATION LEVEL SERIALIZABLE in place of BEGIN ISOLATION LEVEL REPEATABLE READ in both sessions.

Step 6 · SERIALIZABLE

Same statements, same order as Step 5. Both sessions still read 2; Session A's UPDATE and COMMIT still succeed.

Which of Session B's statements fails this time — the UPDATE, or the COMMIT?
Session B
UPDATE doctors SET is_on_call = false WHERE name = 'bob'; COMMIT;
ERROR: could not serialize access due to read/write dependencies among transactions DETAIL: Reason code: Canceled on identification as a pivot, during write. HINT: The transaction might succeed if retried. ROLLBACK

The UPDATE fails, not the COMMIT — note there is no UPDATE 1 line. That trailing ROLLBACK is the next statement, B's COMMIT, reporting that an already-aborted transaction was rolled back instead. Under \set VERBOSITY verbose the first line reads:

ERROR: 40001: could not serialize access due to read/write dependencies among transactions

Postgres's serializable mode detects the read/write dependency cycle between the two transactions — A read data B is now writing, and B read data A already wrote, with no way to order them serially — and cancels B as the pivot of that cycle, at the moment the write arrives rather than at commit time. Your application is expected to catch 40001 and retry.

SELECT * FROM doctors ORDER BY name;
name | is_on_call -------+------------ alice | f bob | t (2 rows)

Invariant holds: exactly one doctor went off call, the other's conflicting update was rejected.


What you should see

A progression where each isolation level silently permits exactly the anomaly the next level up is designed to prevent — dirty reads never happen in Postgres at all, non-repeatable reads are stopped by REPEATABLE READ, same-row lost updates are also stopped by REPEATABLE READ (could not serialize access due to concurrent update), and write skew is the one anomaly that survives all the way up to REPEATABLE READ — precisely because it never touches a shared row — and is only caught by SERIALIZABLE, via an explicit serialization-failure error rather than silent corruption.

Both failures are SQLSTATE 40001 40001 is serialization_failure, and it's the code your retry logic should key on. The two differ in when they fire: the REPEATABLE READ one is a row-level check inside ExecUpdate, while the SERIALIZABLE one comes from SSI's dependency graph. In this interleaving the SSI failure also lands on the UPDATE rather than the COMMIT, because by then B is already identifiable as the pivot. Don't generalize that timing — reorder the steps (see Go deeper) and the same cycle is caught at commit instead, with Reason code: Canceled on identification as a pivot, during commit attempt.

Where each Part goes deeper

This exercise is the ladder — one pass over every rung, so you can see the progression. Each rung has a ddia/ch08 exercise that stops there and takes it apart, reached from Kleppmann's framing rather than the manual's:

Rung hereWhere it goes deeper
Part 1 — no dirty reads dirty-read-impossible asks for READ UNCOMMITTED by name, gets it (SHOW transaction_isolation even answers read uncommitted), and still can't produce the anomaly the level is named for.
Part 2 — non-repeatable reads read-skew makes $100 vanish across two accounts, then proves the snapshot is per-statement rather than inferring it.
Part 2's edge case — same-row 40001 lost-update reaches the same abort as one of three fixes, and prices it against the atomic UPDATE … SET v = v + 1 and SELECT … FOR UPDATE.
Part 3 — write skew write-skew-doctors runs these same two doctors and adds the fix this exercise doesn't: SELECT … FOR UPDATE at READ COMMITTED. phantom-write-skew then shows the case where that fix stops working — a double-booked room, where the conflicting row doesn't exist yet for FOR UPDATE to grab.

And one rung above the top of this ladder: serializable-not-linearizable. SERIALIZABLE ends the progression here, which makes it easy to read as "the level where nothing can go wrong." It isn't. A SERIALIZABLE transaction will happily return a stale value and commit clean, with no 40001 — because serializability constrains the outcome to match some serial order, not when each operation takes effect. That second property is linearizability, and this ladder has no rung for it.

Why

READ COMMITTED re-reads the "currently committed" view on every statement. REPEATABLE READ fixes a single snapshot at the start of the transaction (Postgres implements it via MVCC snapshot isolation, not literal locking) and additionally refuses to let you update a row that's moved underneath you. But snapshot isolation is fundamentally a per-row guarantee: it protects rows you've read or written from changing underneath you, and says nothing about a decision you made based on a read of one row being invalidated by a write to a completely different row. That's write skew, and it's a structural gap in snapshot isolation, not a bug — see the mental model table above for why SERIALIZABLE (or a targeted alternative like a unique constraint or SELECT ... FOR UPDATE) is the right tool specifically when an invariant spans rows that snapshot isolation has no reason to connect.

The mechanism that closes the gap: under SERIALIZABLE, Postgres records a predicate lock (SIREAD) for everything a transaction reads — here, the rows scanned by SELECT count(*) FROM doctors WHERE is_on_call. When another transaction writes a row covered by such a lock, an edge is added to a dependency graph. A transaction with both an incoming and an outgoing "dangerous" edge is a pivot, and a pivot is exactly the signature of a cycle with no serial equivalent; Postgres cancels it. Note what makes this possible: SIREAD locks cover rows read, not just rows written — precisely the information snapshot isolation throws away.

The boundary condition where the effect vanishes: make the two transactions contend on the same row and REPEATABLE READ already catches it (Part 2's edge case), so SERIALIZABLE buys you nothing extra. Equally, run them so they don't overlap in time (A commits before B's BEGIN) and there is no anomaly at any level, because the interleaving with no serial equivalent never happens. The anomaly needs both concurrency and disjoint write sets over an overlapping read set.


Go deeper

Sources: PostgreSQL 16 Docs §13.2: Transaction Isolation (§13.2.3 carries a different write-skew example, mytab(class, value), worth reproducing alongside this one) · Kleppmann, Designing Data-Intensive Applications, 2nd ed., Ch. 8 ("Write Skew and Phantoms"), whose doctors-on-call scenario this is — the exercises mined from that chapter are mapped rung-by-rung under "Where each Part goes deeper" above


Cleanup

The container was started with --rm, so stopping it also removes it; no volumes or networks were created.

docker stop pg-iso
docker ps -a --filter name=pg-iso   # should print only the header row