Internals · MVCCread committedrepeatable readserializablesnapshot isolationssi

Isolation Levels: The Mechanism Behind Each

The same snapshot machinery produces three isolation levels by changing one thing — when the snapshot is taken — plus one rule for writers; Serializable then adds either dependency tracking that aborts (PostgreSQL) or locking reads that block (InnoDB), which is why the same level name costs retries on one engine and waits on the other.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    The practical lesson names four levels and lists which anomalies each allows. But the engine has one visibility rule and one lock manager. How do four different guarantees come out of the same parts?

  2. Naive solution

    Implement each level as a separate code path: one with locks for everything (Serializable), one with no locks at all (Read Uncommitted), and something in between.

  3. Why it breaks

    Four executors, four lock protocols, four sets of bugs — and the lock-everything path blocks readers, which MVCC was built to avoid. The levels also have to be switchable per transaction on the same tables at the same time.

  4. Better idea

    Keep one visibility rule and vary its input: how old the snapshot is. A fresh snapshot per statement sees each statement's committed world; one snapshot per transaction sees a frozen world. Add a rule for what a writer does when the row moved under it. For Serializable, add detection of the one pattern snapshots cannot see.

  5. Internal mechanism

    Read Committed: new snapshot per statement, writers follow the row's version chain to the newest committed version and re-check. Repeatable Read: one snapshot at the first statement, writers that find a concurrent committed update abort (first-updater-wins). Serializable: Repeatable Read plus read-marks and rw-dependency tracking that aborts on a dangerous structure, or plus locking reads that block.

  6. Trade-offs

    Fresher snapshots see more and repeat less; frozen snapshots cost aborts on write conflicts and pin versions longer. Dependency tracking never blocks but aborts under false positives and needs memory for read-marks; locking reads never abort but block and deadlock.

  7. Real database

    PostgreSQL: snapshot per statement / per transaction, EvalPlanQual re-checks, SSI with SIREAD locks at Serializable. InnoDB: read view per statement / per transaction, next-key locks for locking reads, every SELECT a locking read at Serializable.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

One rule, three ages of snapshot

Isolation levels are not separate engines. They are the same visibility rule fed a snapshot of a different age: taken now for this statement (Read Committed), taken once for the whole transaction (Repeatable Read), or taken once and then guarded by a detector that catches what snapshots cannot (Serializable). Writers get one extra rule per level for what to do when the row they want to change was changed by someone else since their snapshot.

One executor, one knob

The practical lesson Isolation Levels describes the levels by what they let you see. Inside the engine there is a single tuple-visibility function (from MVCC Internals: Version Chains and Snapshots) and a single place where a writer discovers that its target has moved; the levels are configurations of those two points. The first knob is snapshot age: Read Committed calls for a new snapshot at the start of every statement, Repeatable Read and Serializable take one at the transaction's first statement and reuse it. That single difference produces the whole non-repeatable-read and phantom distinction, because a snapshot that never changes cannot see a row change or appear.

The second knob is what a writer does when the row moved. An UPDATE locates a tuple through its snapshot, tries to lock it, and finds xmax already set by a concurrent transaction. It waits for that transaction to end. If the other aborted, the update proceeds on the tuple as found. If the other committed, there is a newer version the snapshot cannot see — and here the levels diverge.

The same three operations under three levels (T1 transfers, T2 reads twice)
T2 BEGIN
T2 SELECT balance WHERE id=7        -> 100
T1 UPDATE id=7 SET balance=80; COMMIT
T2 SELECT balance WHERE id=7        -> Read Committed: 80   (new snapshot; xid of T1 now committed)
                                       Repeatable Read: 100 (same snapshot; T1 in its xip list)
                                       Serializable:    100 (same, plus SIREAD lock on the tuple)
T2 UPDATE id=7 SET balance=balance-10
                                       Read Committed: waits (T1 done) -> follows chain to 80 -> writes 70
                                       Repeatable Read: ERROR 40001 could not serialize access
                                                        due to concurrent update
                                       Serializable:    same ERROR (first-updater-wins applies too)

Read Committed: a snapshot per statement and the update chase

PostgreSQL implementation

At Read Committed each statement begins with GetTransactionSnapshot(), so each statement sees everything committed before *it* started. Between two statements of one transaction the world may change; that is the level's definition, not a defect. The interesting behaviour is in UPDATE and DELETE. The statement found tuple version V1 through its snapshot and wants to modify it; but V1's xmax says T1 superseded it and T1 has since committed. Updating V1 would be a lost update. Aborting would make Read Committed useless for counters. So PostgreSQL follows V1's t_ctid chain to the newest committed version V2, re-evaluates the statement's WHERE clause against V2 — the EvalPlanQual re-check — and if it still qualifies, updates V2 instead. The statement effectively sees one row from the future.

This is why UPDATE accounts SET balance = balance - 10 is safe at Read Committed while SELECT balance followed by UPDATE … SET balance = 90 is not: the first re-reads balance from V2 during the chase; the second computed 90 from V1 in the application and the engine has no way to know. It is also why a Read Committed UPDATE can update a row that no longer matches the snapshot it started with, and skip one that the chase found no longer qualifying.

Repeatable Read: snapshot isolation and first-updater-wins

One snapshot, taken at the first statement, for the life of the transaction. Every read repeats; no phantom can appear because an inserted row's xmin is either in the in-progress list or above snap.xmax. The transaction is reading a frozen world, and it costs nothing extra to read it — no locks, no marks. This is snapshot isolation, and both PostgreSQL and InnoDB implement Repeatable Read this way (which is why both prevent phantoms in plain reads, stronger than the SQL standard requires).

Writers under a frozen snapshot have a problem the chase cannot solve: following the chain to a newer version means writing on the basis of data the transaction is not allowed to see. The rule is first-updater-wins (equivalently, first-committer-wins): a transaction that tries to update a row already updated by a concurrent transaction waits for it; if the other commits, the waiter aborts with "could not serialize access due to concurrent update"; if the other aborts, the waiter proceeds. This is exactly what turns the lost-update schedule into an error rather than a wrong answer, and it needs no extra structure — it is the xmax check the writer was already doing, with "abort" in place of "chase". Its blind spot is any conflict with no write–write component: write skew.

Write skew under snapshot isolation: two on-call doctors, both go off call
invariant: at least one of {alice, bob} on_call
snapshot for both: alice on, bob on

TA SELECT count(*) WHERE on_call        -> 2      (SIREAD on the predicate, if Serializable)
TB SELECT count(*) WHERE on_call        -> 2
TA UPDATE doctors SET on_call=false WHERE name='alice'     (row alice: no concurrent xmax -> ok)
TB UPDATE doctors SET on_call=false WHERE name='bob'       (row bob:   no concurrent xmax -> ok)
TA COMMIT   TB COMMIT                   -> nobody on call

Repeatable Read: no ww conflict, no error.   rw-dependencies: TA -> TB (TA read bob, TB wrote bob)
                                                              TB -> TA (TB read alice, TA wrote alice)
Serializable (SSI): dangerous structure found -> one aborts with 40001.

Serializable: detect the dependency and abort

PostgreSQL implementation

PostgreSQL's Serializable is serializable snapshot isolation (Cahill, Röhm and Fekete 2008; in PostgreSQL since 9.1). It starts as Repeatable Read and adds bookkeeping. Every read records an SIREAD lock on what was read — a tuple, or an index page or the whole relation when the read was a range or a scan — in a shared-memory predicate lock table separate from the ordinary lock manager. SIREAD locks conflict with nothing; they are memory, not blocking. When a transaction *writes* a tuple, the engine checks whether a concurrent transaction holds an SIREAD lock covering it and, if so, records a rw-dependency edge from the reader to the writer: "the reader saw a version the writer replaced". Edges are also found in the other direction, when a read encounters a tuple written by a concurrent transaction.

The theory says every non-serializable execution under snapshot isolation contains a transaction with an rw-edge *in* and an rw-edge *out*, both to transactions concurrent with it, where the transaction at the end of the out-edge commits first. That is the dangerous structure. PostgreSQL checks for it at each new edge and at commit, and when it finds one it aborts a transaction in it with SQLSTATE 40001 — preferring the one that has not committed yet, and never one that has. The check is conservative: it does not verify that a full cycle exists, so some transactions abort that would have been fine (false positives), particularly when many tuple-level SIREAD locks are promoted to page or relation locks to fit max_pred_locks_per_transaction. What it never does is wait. A Serializable transaction in PostgreSQL blocks in exactly the places a Repeatable Read one does, and pays for its guarantee in retries.

That is the answer to why Serializable "costs retries rather than blocking" here: the mechanism is detection over snapshots, not prevention with locks. The application must be built for 40001 as normal operation — the practical lesson shows the loop — and hot spots with many overlapping reads and writes will see abort rates that make the level unusable, which is when a deliberate SELECT … FOR UPDATE on a single representative row is the right tool instead.

SSI bookkeeping at the two points where it hooks the executor
1on_read(txn, tuple_or_range):
2 predlock.add(SIREAD, target, txn) # blocks nobody
3 for w in concurrent_writers_of(target): # tuple written since my snapshot
4 record_rw_edge(reader=txn, writer=w) # txn -> w
5
6on_write(txn, tuple):
7 for r in predlock.holders(SIREAD, tuple) if concurrent(r, txn):
8 record_rw_edge(reader=r, writer=txn) # r -> txn
9 check_dangerous(txn)
10
11check_dangerous(t): # t has in-edge and out-edge
12 if any(e.reader for e in t.in_edges) and any(e.writer for e in t.out_edges):
13 if out_edge_target_committed_first: abort(pick_victim(t)) # SQLSTATE 40001

Serializable: lock the reads and block

MySQL / InnoDB implementation

InnoDB takes the other road. Its Repeatable Read is snapshot isolation for plain SELECTs (a read view fixed at the first read) but its locking reads and writes already use next-key locks, so SELECT … FOR UPDATE over a range blocks inserts into that range and phantoms cannot appear in locked ranges. Setting the level to SERIALIZABLE changes one thing: every plain SELECT is executed as SELECT … FOR SHARE (unless autocommit is on and the statement is a lone read). Reads now take shared next-key locks held to commit; a concurrent write to anything a transaction read must wait for it. This is strict two-phase locking, and the theorem from Concurrency Control: Schedules and Serializability guarantees serializability.

The write-skew example blocks instead of aborting: TA's SELECT locks both doctor rows in shared mode, TB's SELECT does too, TA's UPDATE of alice needs an exclusive lock and waits for TB's shared lock, TB's UPDATE of bob waits for TA's — a deadlock, which the detector breaks by aborting one. Same outcome as SSI in the end, reached by blocking rather than by tracking, and with the costs of blocking: readers stall writers, throughput under contention falls, and a long Serializable report holds shared locks on everything it read. Read Committed in InnoDB, for symmetry, uses a read view per statement and drops gap locks for ordinary statements.

Anomaly, level, mechanism

The matrix from the practical lesson said *whether* each level prevents each anomaly. This one says *what* prevents it. Read it column by column: the same anomaly is stopped by a different part of the engine at each level, and where two engines differ in mechanism, they differ in cost.

Which mechanism prevents which anomaly at each level (PG = PostgreSQL, I = InnoDB)
AnomalyRead CommittedRepeatable ReadSerializable
Dirty readprevented: visibility rule never shows in-progress xminprevented: sameprevented: same
Non-repeatable readpossible: new snapshot per statementprevented: one snapshot per transactionprevented: one snapshot
Phantom (plain reads)possible: new snapshotprevented: one snapshot (PG and I)prevented: one snapshot / next-key locks
Phantom (locking reads)possible: PG chase, I no gap locksPG possible (chase); I prevented by next-key locksPG prevented (SSI); I prevented (next-key)
Lost updatepossible: EvalPlanQual chases to newest versionprevented: first-updater-wins abortsprevented: first-updater-wins
Write skewpossiblepossible: no ww conflict to detectPG prevented: rw-dependency abort; I prevented: SELECTs take shared next-key locks

Costs, by mechanism

Snapshot-per-statement is almost free: a walk of the running list per statement. Snapshot-per-transaction costs nothing to take and something to keep — every version created after it is pinned until it ends, which is the horizon problem from UPDATE, DELETE and Dead Tuples. First-updater-wins costs aborts on write–write conflicts, which is the same work a lock wait would have spent, spent differently. SSI costs memory for predicate locks, CPU at every read and write for the edge checks, and aborts including false positives; it never adds a wait. Locking reads cost waits and deadlocks and never add an abort of their own.

Choose by workload shape, not by name. Many short conflicting transactions: locking reads or explicit FOR UPDATE, because retries would be the workload. Many long reads with occasional writes: SSI, because reads must not block. And read the other engine's documentation before assuming its Serializable means the same thing — Oracle's is snapshot isolation and permits write skew.

Key points

  • The levels are one visibility rule with a snapshot of different age: per statement (Read Committed) or per transaction (Repeatable Read, Serializable).
  • Writers differ by level when the row moved: Read Committed chases the version chain and re-checks (EvalPlanQual); Repeatable Read aborts (first-updater-wins).
  • Snapshot isolation prevents dirty, non-repeatable and phantom reads without read locks and cannot see write skew because there is no write–write conflict.
  • PostgreSQL Serializable = SSI: non-blocking SIREAD marks, rw-dependency edges, abort on a dangerous structure — retries, never waits.
  • InnoDB Serializable = strict 2PL: every SELECT takes shared next-key locks — waits and deadlocks, never serialization aborts.

Visualize isolation

Visualize isolation
One fixed script — a re-read, a phantom, a concurrent update and a write-skew pair — executed under the three isolation levels PostgreSQL implements. Click a statement to see the snapshot each level used.
ttxstatementRead Committed
snapshot per statement
Repeatable Read
snapshot per transaction
Serializable
snapshot per transaction + rw-dependency tracking
non-repeatable read
1T1BEGIN;xid 101xid 101xid 101
2T1SELECT x FROM t;100100100
3T2BEGIN;xid 102xid 102xid 102
4T2UPDATE t SET x = x - 20;UPDATE 1 → 80UPDATE 1 → 80UPDATE 1 → 80
5T2COMMIT;COMMITCOMMITCOMMIT
6T1SELECT x FROM t;80100100
phantom
7T1SELECT count(*) FROM t WHERE kind = 'pending';222
8T3BEGIN;xid 103xid 103xid 103
9T3INSERT INTO t (kind, value) VALUES ('pending', 1);INSERT 1INSERT 1INSERT 1
10T3COMMIT;COMMITCOMMITCOMMIT
11T1SELECT count(*) FROM t WHERE kind = 'pending';322
concurrent update
12T1UPDATE t SET x = x - 10;UPDATE 1 → 70ERROR 40001ERROR 40001
13T1COMMIT;COMMITROLLBACKROLLBACK
write skew
14T4BEGIN;xid 104xid 104xid 104
15T5BEGIN;xid 105xid 105xid 105
16T4SELECT alice FROM t;111
17T4SELECT bob FROM t;111
18T5SELECT alice FROM t;111
19T5SELECT bob FROM t;111
20T4UPDATE t SET alice = alice - 1;UPDATE 1 → 0UPDATE 1 → 0UPDATE 1 → 0
21T5UPDATE t SET bob = bob - 1;UPDATE 1 → 0UPDATE 1 → 0UPDATE 1 → 0
22T4COMMIT;COMMITCOMMITCOMMIT
23T5COMMIT;COMMITCOMMITERROR 40001
Read Committed
80
fresh per-statement snapshot: xmin=103 xmax=103 xip={}

version v6 (xmin 102, xmax ∅) is the one visible to xmin=103 xmax=103 xip={}

Repeatable Read
100
reusing transaction snapshot: xmin=102 xmax=102 xip={}

version v1 (xmin 100, xmax 102) is the one visible to xmin=102 xmax=102 xip={}

Serializable
100
reusing transaction snapshot: xmin=102 xmax=102 xip={}

version v1 (xmin 100, xmax 102) is the one visible to xmin=102 xmax=102 xip={}

Read Committed: serialization failures
0
Repeatable Read: serialization failures
1
Serializable: serialization failures
2
Read Committed

The default. Every statement takes a fresh snapshot, so it always sees the latest committed data — and two statements in one transaction may disagree. An UPDATE that meets a newer committed version re-checks its WHERE clause against it and proceeds.

Repeatable Read

Snapshot isolation: one snapshot at the first statement, reused by every read. Stronger than the SQL standard requires (no phantoms either). An UPDATE that meets a version committed after the snapshot fails with 40001 — first-updater-wins — so lost updates are impossible, but write skew is not.

Serializable

Serializable Snapshot Isolation: the same snapshot as Repeatable Read plus SIREAD locks that record what each transaction read. At commit the engine looks for two consecutive rw-antidependencies among concurrent transactions and aborts one with 40001. The only level that catches write skew; retries are normal operation.

PostgreSQL implementationRead Uncommitted is accepted and silently runs as Read Committed; MVCC has no way to show an uncommitted version.
Educational simulation — the visibility function is the one from the version-chain interactive; SSI is reduced to its two-rw-edge rule and first-committer-wins.

When to use — and when not

Use it when
  • Snapshot-per-statement fits high-throughput request handling with self-contained statements.
  • Snapshot isolation fits reports and multi-read transactions that need a consistent view without locking.
  • SSI fits workloads with long reads and rare true conflicts that can afford a retry loop; locking reads fit short, hot, conflicting transactions where a retry costs more than a wait.
Avoid it when
  • SSI does not fit hot-spot workloads: abort rates, including false positives from lock promotion, make retries the dominant cost.
  • Locking reads at Serializable do not fit long analytical transactions: they hold shared locks on everything read until commit.

Failure modes

  • Serializable in PostgreSQL without a retry loop: 40001 treated as an outage.
  • Read Committed read-then-write in application code, expecting the chase to protect a value computed client-side.
  • Assuming InnoDB Repeatable Read prevents write skew because its locking reads have gap locks — plain SELECTs do not lock.
  • Predicate-lock promotion to relation level under memory pressure, aborting unrelated Serializable transactions.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.