4.4.17 · D3Databases

Worked examples — Transaction isolation levels — READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE

3,576 words16 min readBack to topic

This page is the drill floor for the parent topic. We will hit every kind of case the isolation dial can throw at you: each anomaly, each level, the "nothing happens" baseline, the degenerate rollback case, the limiting case (SERIALIZABLE aborts), a real-world word problem, and an exam twist (write skew, which the standard table does not cover).

If a word here is unfamiliar (dirty read, snapshot, predicate lock), it is fully built in the parent note — this page assumes you read that first.


The scenario matrix

Think of a scenario as a point in a grid. The rows are what T2 does and when; the columns are which isolation level T1 runs at. A cell is either "anomaly happens" (bad) or "prevented" (safe). We must show at least one worked example touching every kind of cell.

Each ✅/❌ below is earned by a worked example. Nothing is asserted without a trace.


Ex 1 — Dirty read at READ UNCOMMITTED · (dirty row, RU column)

Steps.

  1. T1 reads bal. Why this step? RU imposes no rule that T2's write-lock be released first, so T1 peeks at the in-progress value → T1 reads 0.
  2. T2 rolls back. Why this step? A ROLLBACK undoes T2's uncommitted write, so the row's committed value returns to 100. The 0 T1 saw never officially existed.
  3. Final committed bal = 100, but T1 already acted on 0. Why this step? This is the definition of a dirty read — T1's logic is now based on a value the database will disown.

The timeline below traces this exactly: the white dashed arrow shows T1's read reaching up into T2's uncommitted lane to grab the 0, and the red ROLLBACK marker on T2's lane shows the value snapping back to 100 afterwards.

Figure — Transaction isolation levels — READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE
Recall Verify

T1 read = 0 (uncommitted). Final committed bal = 100. The read value ≠ final value ⇒ dirty read confirmed. See =VERIFY= dirty_read_value.


Ex 2 — Same scenario, raised to READ COMMITTED · (dirty row, RC column = safe)

Steps.

  1. T1 issues its SELECT while T2's write is uncommitted. Why this step? RC's contract: only committed data is visible. Under MVCC, T1 reads the last committed version = 100. (Under locking, T1 would block until T2 commits/rolls back, then read 100.)
  2. T2 rolls back. Why this step? No effect on T1 — T1 never looked at the uncommitted 0.
Recall Verify

T1 read at RC = 100 = final committed value. No dirty read. This is exactly the ✅ (prevented) cell in the RC column. See =VERIFY= rc_prevents_dirty.


Ex 3a — Non-repeatable read at READ UNCOMMITTED · (update row, RU column = anomaly)

Steps.

  1. Read A = 100. Why this step? No writer is active yet, so T1 reads the current committed value.
  2. T2 commits bal=50. Why this step? 50 is now the committed value.
  3. Read B = 50. Why this step? RU has no snapshot stability at all — it always sees the newest value (committed or not). So the second read certainly reflects T2's committed 50.
  4. Read A ≠ Read B. Why this step? Two answers to the same query ⇒ non-repeatable read. RU never prevents it — this fills the ❌ RU cell.
Recall Verify

Read A = 100, Read B = 50, unequal ⇒ non-repeatable read at RU. See =VERIFY= nonrepeatable_ru.


Ex 3 — Non-repeatable read at READ COMMITTED · (update row, RC column = anomaly)

Steps.

  1. Read A = 100. Why this step? T1's first statement takes a fresh snapshot; committed value is 100.
  2. T2 commits bal=50. Why this step? Now 50 is the committed value.
  3. Read B. Why this step? RC takes a new snapshot per statement, so the second SELECT sees T2's freshly committed 50. Read B = 50.
  4. Read A ≠ Read B. Why this step? Same query, same row, two answers → non-repeatable read.
Recall Verify

Read A = 100, Read B = 50, 100 ≠ 50 ⇒ non-repeatable read. See =VERIFY= nonrepeatable_rc.


Ex 4 — Same scenario, raised to REPEATABLE READ · (update row, RR column = safe)

Steps.

  1. Read A = 100. Why this step? T1's first statement takes the transaction's one and only snapshot, timestamped before T2 committed.
  2. T2 commits bal=50. Why this step? This creates a new version of the row, but it is newer than T1's frozen snapshot.
  3. Read B. Why this step? RR reuses the start-of-transaction snapshot forever, so T1 ignores T2's newer version. Read B = 100.
  4. Read A = Read B. Why this step? The read is now repeatable — this is the ✅ (prevented) cell.
Recall Verify

Read A = Read B = 100. Difference = 0 ⇒ repeatable. See =VERIFY= rr_prevents_nonrepeatable.

The figure below contrasts Ex 3 and Ex 4 on two timelines. Top (red) lane = RC: the two blue read-dots return 100 then 50 — different. Bottom (green) lane = RR: both read-dots return 100 because the white double-arrow bracket shows one frozen snapshot spanning the whole transaction, so T2's commit-marker in the middle is ignored.

Figure — Transaction isolation levels — READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE

This snapshot machinery is MVCC (Multi-Version Concurrency Control); the lock-based alternative is Two-Phase Locking (2PL).


Ex 5a — Phantom read at READ UNCOMMITTED and READ COMMITTED · (insert-matching-predicate, RU + RC columns = anomaly)

Steps.

  1. Count A = 5. Why this step? Five existing rows match the predicate at each level's first read.
  2. T2 inserts age=40 and commits. Why this step? 40 > 30, so this new row joins the matching set.
  3. At RU, count B = 6. Why this step? RU offers no read stability whatsoever, so the newest matching set (now 6 rows) is visible immediately — phantom appears. This fills the ❌ RU phantom cell.
  4. At RC, count B = 6. Why this step? RC takes a fresh snapshot per statement, so the second SELECT sees T2's committed insert. RC prevents dirty reads but not phantoms — this fills the ❌ RC phantom cell.
  5. Count A ≠ count B at both levels ⇒ phantom read confirmed for RU and RC.
Recall Verify

RU: count A = 5, count B = 6. RC: count A = 5, count B = 6. Both show one phantom. See =VERIFY= phantom_ru_rc.


Ex 5 — Phantom read at REPEATABLE READ · (insert-matching-predicate, RR column = ⚠️)

Steps.

  1. Count A = 5. Why this step? Five existing rows satisfy the predicate.
  2. T2 inserts age=40 and commits. Why this step? 40 > 30, so this brand-new row matches T1's WHERE.
  3. Count B — by the SQL standard = 6. Why this step? Standard RR protects rows you already read, not predicate membership. A new matching row is a phantom and may appear.
  4. But in PostgreSQL RR reuses the start snapshot, so the new row (committed after the snapshot) is invisible → PG returns 5. Why this step? Engines may exceed the standard's minimum. This is the ⚠️ in the matrix.
  5. Fix to guarantee prevention everywhere: SERIALIZABLE places a predicate/range lock on age>30, blocking T2's insert until T1 finishes.
Recall Verify

Standard-behaviour count A = 5, count B = 6, difference = 1 phantom row. See =VERIFY= phantom_count.


Ex 6 — Baseline & degenerate at EVERY level · (no-conflict + degenerate cells, all columns)

Steps.

  1. Case (a): read A = 100 at every level. Why this step? committed value; all four levels agree on the first read.
  2. T2 updates id=99 — an unrelated row. Why this step? No overlap with T1's read set ⇒ no interference is possible, so the level cannot matter.
  3. Case (a): read B = 100 at RU, RC, RR, and SER. Why this step? Even RU (which sees everything) has nothing to see about id=1; and RR/SER's snapshots trivially still show 100. Nobody touched T1's row.
  4. Case (b): both reads = 100 at every level. Why this step? With no concurrent writer at all, there is no version to diverge to — this is the degenerate "isolation has nothing to protect against" case, true for all four columns.
Recall Verify

(a) reads (100, 100) equal at all four levels; (b) reads (100, 100) equal at all four levels. No anomaly in any cell. This fills the two "always ✅" rows across every column. See =VERIFY= baseline_no_conflict.


Ex 7 — Limiting case: SERIALIZABLE aborts a transaction · (SER limiting cell)

Steps.

  1. Both read x = 10. Why this step? Each takes its own snapshot; concurrent reads are fine.
  2. Naive concurrent commit: whichever writes last wins → x = 15 or 11, and the other update is lost. Why this step? Neither result equals any serial order: serial T1→T2 gives x = 10+1 then +5 = 16; serial T2→T1 gives 10+5 then +1 = 16. Both serial orders give 16, but the concurrent write-based-on-stale-read gives 11 or 15 — impossible serially.
  3. SER detects the read-write dependency cycle and aborts one transaction with serialization_failure. Why this step? SER guarantees result = some serial order; since no serial order yields 11 or 15, it refuses to let both through. The app retries the aborted one.
  4. On retry, the loser re-reads the now-committed value and produces x = 16. Why this step? After retry the two runs are effectively serial, giving the correct 16.
Recall Verify

Serial T1→T2 = 16, serial T2→T1 = 16; naive concurrent = 11 or 15 (≠ 16). SER must abort to force 16. See =VERIFY= ser_abort_forces_serial.

This abort-and-retry mechanism is Serializable Snapshot Isolation (SSI); forcing serial order can also produce Deadlocks under locking implementations.


Ex 8 — Word problem: bank overdraft race · (real-world, needs SER)

Steps.

  1. Both read bal = 100 into their own variable r. Why this step? Under RC/RR, concurrent reads both see the committed 100; neither sees the other's pending withdrawal.
  2. Both pass the check 100 >= 80. Why this step? Each transaction independently believes the money is there.
  3. Both compute r - 80 = 20 and write bal = 20. Why this step? Crucially, each writes an absolute value computed from its stale read of 100, so the second commit overwrites the first with the same 20 — a lost update. Final bal = 20, yet two withdrawals of 80 each were "approved" and handed out as cash. Money that left the account (cash dispensed) = 80 + 80 = 160, but the ledger only dropped by 100 - 20 = 80. The account is short by 80 — an accounting hole no serial order allows.
  4. Fix — SERIALIZABLE. Why this step? SER must equal a serial order. In serial T1→T2, T2 reads the committed 20, fails 20 >= 80, and dispenses nothing — only one withdrawal of 80 happens and bal = 20 matches the 80 dispensed. SER detects the read-write conflict, aborts one transaction, and on retry the loser correctly rejects. This closes the accounting hole.
Recall Verify

Anomalous (RC/RR) outcome: cash dispensed = 160 while ledger dropped only 80 (illegal short of 80). Serial outcome: cash dispensed = 80, ledger drop = 80, final bal = 20 (consistent). See =VERIFY= overdraft_serial_safe.


Ex 9 — Exam twist: write skew · (anomaly outside the standard table)

Steps.

  1. Both read count = 2. Why this step? Each RR snapshot sees both doctors on-call.
  2. Both pass 2 >= 2. Why this step? Each independently believes it's safe to leave — the classic write skew setup.
  3. T1 sets Alice off; T2 sets Bob off. Why this step? They write to different rows, so there's no update conflict — RR (and even next-key locks) see nothing to block.
  4. Final on-call count = 0 → rule violated. Why this step? This is Write skew anomaly: two transactions read an overlapping set, then each writes a disjoint part, together breaking an invariant. The SQL standard's table (dirty / non-repeatable / phantom) does not name it, which is the exam trap.
  5. Fix: SERIALIZABLE. Under SSI it detects the read-write dependency (each read the set the other modified) and aborts one, leaving count = 1.

The figure shows the trap: both blue reads see 2, the yellow box (T1→Alice) and red box (T2→Bob) write different rows so no conflict is detected, and the red result banner shows on-call collapsing to 0.

Figure — Transaction isolation levels — READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE

Recall Full matrix recap (self-test)

Which example proved RC prevents dirty reads? ::: Ex 2 (read = 100, not 0). Which anomaly appears at RC but not RR? ::: Non-repeatable read (Ex 3 vs Ex 4). At which levels does a non-repeatable read happen? ::: RU (Ex 3a) and RC (Ex 3); RR and SER prevent it. At which levels does a phantom happen per the standard? ::: RU and RC (Ex 5a), and RR (Ex 5, ⚠️ blocked in practice); SER prevents it. Why does REPEATABLE READ fail the overdraft (Ex 8) and write-skew (Ex 9) cases? ::: Because both writers act on stale snapshots / disjoint rows; RR stabilises reads but not cross-row write validity. What does SERIALIZABLE do when it can't serialise? ::: Aborts one transaction with a serialization failure to retry (Ex 7).