Databases
Part 2 of 6 · MVCC & isolationIsolation Levels Deep Dive
Isolation levels are the contract for what concurrent transactions may see. ANSI labels hide engine gaps: Postgres RR is SI, MySQL RR uses gap locks, and write skew sits outside the ANSI phenomena list.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Overview
Isolation is the contract between the engine and your code: what concurrent transactions may see, and when they may commit. The wrong level silently corrupts business rules (double-book a seat, drain inventory). The right-but-too-strong level burns throughput in aborts and lock waits.
ANSI-SQL labels are a shared vocabulary, not a wire protocol. PostgreSQL REPEATABLE READ is Snapshot Isolation. MySQL InnoDB REPEATABLE READ is next-key locking. Write skew is not even on the ANSI phenomena list. Senior answers name the engine, then the anomaly, then the fix.
By the end of this lesson you should be able to:
- Walk dirty / non-repeatable / phantom / write skew on a whiteboard
- Translate ANSI names into Postgres and InnoDB behavior
- Choose RC vs RR vs SERIALIZABLE for a given invariant
- Set the level per transaction and retry serialization failures
- Know when compensating transactions beat a global SERIALIZABLE tax
Why the weakest honest level wins
Always-SERIALIZABLE is easy to say and expensive to run. The winning path is the weakest isolation that still preserves the invariant, with locks or constraints only where SI has a hole.
| Path | What you get | What you pay | When it is honest |
|---|---|---|---|
| Stay on READ COMMITTED | No dirty reads; statement-fresh snapshots | Non-repeatable reads, phantoms, write skew | Catalog browse, simple CRUD |
| Postgres REPEATABLE READ (SI) | One snapshot; same-row lost updates abort | Write skew and some phantoms | Multi-statement same-row money |
| Always SERIALIZABLE | True serializability (Postgres SSI) or locking reads (InnoDB) | Aborts (40001) or gap-lock stalls | Multi-row invariants |
- 1
simple CRUD → fresh statement snapshot
Default READ COMMITTED everywhere
Cheap. Dirty reads are gone. Two SELECTs in one txn can disagree. Fine for a product catalog; wrong for “sum of these rows stays ≥ 0”.
- 2
name the anomaly → pick RC, SI, SSI, or a lock
Winner: weakest level that preserves the invariant
Same-row debit: Postgres RR (SI) or an atomic UPDATE … WHERE qty ≥ n. Multi-row “at least one”: SSI retry, FOR UPDATE on the read set, or a constraint — not a global SERIALIZABLE default.
- ?
SERIALIZABLE as a scalpel, not a religion
Postgres SERIALIZABLE is SSI: non-blocking reads plus commit-time rw-checks. InnoDB SERIALIZABLE turns SELECTs into locking reads. High abort or gap-lock cost means you picked too strong, or the key set is too hot — drop to locks on a small set. Details: SSI vs SI and SELECT FOR UPDATE.
ANSI-SQL isolation levels
Four names, in increasing strictness as the spec tells the story:
| ANSI level | Dirty read | Non-repeatable read | Phantom | Typical use |
|---|---|---|---|---|
| READ UNCOMMITTED | allowed | allowed | allowed | Rare; analytics that can tolerate lies |
| READ COMMITTED | prevented | allowed | allowed | Default OLTP (Postgres) |
| REPEATABLE READ | prevented | prevented | allowed (ANSI) | Same-row financial debit |
| SERIALIZABLE | prevented | prevented | prevented | Multi-row invariants |
That table is the ANSI claim. It is not a proof of true serializability, and engines do not implement the cells the same way.
READ UNCOMMITTED
A transaction may read another transaction’s uncommitted writes. If the writer rolls back, the reader observed a value that never existed. Almost never acceptable on OLTP. Postgres does not really give you this: requesting it behaves like READ COMMITTED. InnoDB treats it as READ COMMITTED as well.
READ COMMITTED
Every value you read was committed at the moment of that statement. No dirty reads. Each statement may take a new snapshot, so two SELECTs of the same row in one transaction can disagree. Phantoms and write skew are both in play.
REPEATABLE READ (ANSI)
Rows you already read must not change for the rest of the transaction. Phantoms — new rows matching a predicate — may still appear. This is the weak ANSI definition. Postgres RR is stronger (SI). InnoDB RR is stronger in a different direction (gap locks).
SERIALIZABLE (ANSI)
The outcome should be equivalent to some serial order. ANSI operationalizes this as “no dirty, non-repeatable, or phantom reads.” Berenson et al. showed that blocking those three phenomena is not the same as conflict serializability. Write skew slips through the gap. Postgres closes it with SSI; InnoDB approximates with locking reads.
The three ANSI phenomena
Dirty read. T1 reads a row T2 has modified but not committed. T2 rolls back. T1 acted on a ghost.
Non-repeatable read. T1 reads a row. T2 commits an update (or delete) of that same row. T1 reads it again and sees a different value. Same primary key, different committed version.
Phantom read. T1 runs SELECT … WHERE a predicate. T2 inserts or deletes rows that match the predicate and commits. T1 re-runs the same query and the set changed. No existing row mutated; the membership changed.
Write skew is none of those three. Two transactions read overlapping data, write disjoint rows, and the pair violates a multi-row invariant. ANSI never named it. Full contrast: Phantoms vs write skew. Versions and snapshots: MVCC hub.
Write skew sits outside ANSI
Classic shape (doctors on call): invariant “at least one of Alice or Bob stays on.” Both transactions snapshot two-on, each turns a different doctor off, both commit. No dirty read, no non-repeatable read of the same row, no phantom insert. SI is happy. The invariant is dead.
ANSI SERIALIZABLE-as-phenomena-prevention does not automatically catch this. Engines need extra machinery: Postgres SSI tracks rw-conflicts and aborts one txn; InnoDB gap / next-key locks can block some (not all) skew-shaped inserts; a CHECK / guard row / SELECT … FOR UPDATE makes the invariant physical.
Engine implementations
PostgreSQL
| You SET | Snapshot | Same-row lost update | Phantoms | Write skew |
|---|---|---|---|---|
| READ COMMITTED (default) | Per statement | Possible without care | Possible | Possible |
| REPEATABLE READ | First statement, reused (SI) | First committer wins; loser errors | Predicate inserts can still appear | Allowed |
| SERIALIZABLE | SI + rw-conflict tracking (SSI) | Prevented | Dangerous structures abort | Aborted (SQLSTATE 40001) |
Postgres RC takes a fresh snapshot per statement. RR takes one snapshot and reuses it — that is Snapshot Isolation, stronger than weak ANSI RR. SERIALIZABLE is SSI, not 2PL: reads stay non-blocking until commit-time checks. Details: SSI vs Snapshot Isolation.
MySQL InnoDB
| You SET | Mechanism | Notes |
|---|---|---|
| READ COMMITTED | Statement snapshot; no gap locks on ordinary reads | Similar idea to Postgres RC |
| REPEATABLE READ (default) | Consistent read plus next-key (record + gap) locks on locking reads / writes | Stricter than ANSI RR; phantoms often blocked; range scans can stall inserts in the gap |
| SERIALIZABLE | Every plain SELECT becomes a locking read | Closer to 2PL than to SSI; more blocking, fewer silent skews, more deadlocks |
Do not assume “REPEATABLE READ” is portable. Quote the engine in the interview.
Sequence
- 1
Application
Step 1 – BEGIN
- 2
Application → Database
BEGIN
- 3
Application
Step 2 – set isolation before the first statement
- 4
Application → Database
SET TRANSACTION ISOLATION LEVEL RR or SERIALIZABLE
- 5
Application
Step 3 – first read (snapshot or locks attach here)
- 6
Application → Database
SELECT balance WHERE id = 42
- 7
Database → Application
committed row
- 8
Application
Step 4 – concurrent txn updates the same key and commits
- 9
Database → Database
other session UPDATE then COMMIT
- 10
Application
Step 5 – second read (RC may see new value; SI will not)
- 11
Application → Database
SELECT balance WHERE id = 42
- 12
Database → Application
same version or new version
- 13
Application
Step 6 – COMMIT, or abort on SSI / deadlock
- 14
Application → Database
COMMIT or ROLLBACK
Lesson map
Isolation Levels Deep Dive
Isolation levels are the contract for what concurrent transactions may see. ANSI labels hide engine gaps: Postgres RR is SI, MySQL RR uses gap locks, and write skew sits outside the ANSI phenomena list.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB app["Application"] db["Database"] app -->|BEGIN| db app -->|SET TRANSACTION| db app -->|SELECT balance| db db -->|committed row| app db -->|same version or| app app -->|COMMIT or| db
When to choose each level
READ UNCOMMITTED. Almost never on a primary. If you want dirty-cheap scans, use a replica and accept lag, not RU on the writer.
READ COMMITTED. Default for most OLTP where a second SELECT seeing a newer committed price is fine. Combine with atomic UPDATE … WHERE predicates or app version columns when a single row must not be lost-updated.
REPEATABLE READ. Postgres: multi-statement SI — reports, same-row lost-update protection, still not multi-row invariants. InnoDB: default; know your gap locks on WHERE id > 100.
SERIALIZABLE / SSI. Invariants that span rows or predicates: seat maps, “at least one on call,” multi-account “sum ≥ 0.” Expect aborts (Postgres) or lock waits (InnoDB). Keep transactions short.
Distributed systems often run weaker isolation at each node and compensate in the app (idempotency, sagas). That is a choice, not an accident — name the anomaly you accepted.
Practical patterns
Per-transaction SET. Issue isolation after BEGIN / before the first query. Changing the level mid-transaction is undefined. Do not mix levels in one session’s in-flight txn.
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- first statement takes the SI snapshot (Postgres)
SELECT balance FROM accounts WHERE id = 42;
COMMIT;Optimistic concurrency on RC. UPDATE t SET …, version = version + 1 WHERE id = $1 AND version = $2. Zero rows means lost update — retry. This does not close write skew.
Compensating transactions. If you must stay on RC for throughput, make rare anomalies idempotent to undo: reverse a double-book with a unique seat constraint plus a refund path, rather than pretending isolation will save you.
Bulk load. SERIALIZABLE on a million-row insert serializes work you do not need. COPY / lower isolation, then validate.
In-memory isolation (run this)
No network, no driver. A tiny heap plus per-statement vs per-txn snapshots. I/O contract: isolation name in, visible values and whether both writers commit out.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Deep dive · Postgres RR is SI; InnoDB RR is gap locks
Snapshot Isolation gives every txn a consistent committed prefix. It prevents dirty reads, non-repeatable reads of rows that existed at snapshot time, and same-row lost updates (first committer wins). It does not produce a serial history when writes are disjoint. InnoDB REPEATABLE READ instead uses next-key locks on the write path: a gap between index keys is locked so another session cannot insert a phantom into that gap. That is why WHERE id > 100 under InnoDB RR can block inserts you did not “touch.” SSI (Postgres SERIALIZABLE) takes the other fork: no extra blocking on ordinary reads, abort later if a dangerous rw-cycle appears. Same English label, opposite implementation bet.
Interview Q&A
What are the four ANSI isolation levels and which phenomena does each prevent?
Answer
READ UNCOMMITTED: none of the three. READ COMMITTED: dirty reads only. REPEATABLE READ: dirty and non-repeatable; phantoms still allowed under the ANSI definition. SERIALIZABLE: all three ANSI phenomena. Then immediately add: write skew is not on that list, and engine labels do not match the cells 1:1.
Walk a dirty read, a non-repeatable read, and a phantom.
Answer
Dirty: T1 reads T2’s uncommitted UPDATE; T2 rolls back. Non-repeatable: T1 reads row R, T2 commits UPDATE/DELETE of R, T1 reads R again and the value changed. Phantom: T1 counts status = pending, T2 inserts a new pending row and commits, T1’s recount grew. Same-row mutation vs predicate membership — keep those distinct. See phantoms vs write skew.
Explain write skew and why ANSI SERIALIZABLE as phenomena-prevention misses it.
Answer
Two txns read overlapping state, write disjoint rows, and the pair breaks a multi-row invariant (doctors both go off call; two transfers drain a joint balance). No conflicting write on the same row, so SI and the ANSI three-phenomena story both look clean. True serializability needs SSI, predicate/gap locks, or a physical constraint. Berenson’s critique is the citation.
How does Postgres SERIALIZABLE (SSI) differ from MySQL REPEATABLE READ with gap locks?
Answer
Postgres SSI is SI plus SIREAD/predicate bookkeeping and a commit-time dangerous-structure check; ordinary SELECTs do not block writers; conflicts abort with 40001. InnoDB RR uses next-key (record + gap) locks so many phantoms never appear, at the cost of blocking inserts in locked gaps. InnoDB SERIALIZABLE additionally locks plain SELECTs. One aborts, the other waits.
When would you deliberately choose READ COMMITTED over SERIALIZABLE in a high-traffic service?
Answer
When the business tolerates a later statement seeing a newer committed value (product price, feed) and the abort or gap-lock cost of SERIALIZABLE would dominate. Protect single-row lost updates with UPDATE … WHERE version = $n or an atomic decrement, not a global SSI tax. Prefer the weakest honest level.
How do you set isolation for one transaction in Python and TypeScript?
Answer
After connecting with autocommit off: SET TRANSACTION ISOLATION LEVEL SERIALIZABLE (or REPEATABLE READ) before the first statement. In node-pg, BEGIN ISOLATION LEVEL REPEATABLE READ is the same idea. Changing the level after a query has already started the txn is undefined. Retry the whole txn on 40001, not the last statement.
What are typical symptoms of SERIALIZABLE pain, and how do you mitigate?
Answer
Postgres: could not serialize access / SQLSTATE 40001 under load. InnoDB: lock waits, deadlocks, gap-lock stalls on range writes. Mitigate: short txns, lock rows in a consistent order, retry with jitter, or downgrade to RC/SI plus a constraint or SELECT FOR UPDATE on a small key set. Bulk inserts should not run SERIALIZABLE.
Does SELECT FOR UPDATE under READ COMMITTED close phantoms for the rest of the txn?
Answer
It exclusive-locks the rows it found. Under RC a later statement can see newly committed rows that were not locked. Inserts that match the predicate are not automatically blocked the way InnoDB gap locks or SSI predicate locks would. Use RR/SSI, gap/predicate locks, or a unique/EXCLUDE constraint. Lock duration and wait policy: row locks.
Why is mixing isolation levels in one session a pitfall?
Answer
The snapshot or lock regime is chosen when the transaction starts. Flipping the level after the first statement does not retroactively rewrite what you already saw. Set once, at BEGIN, for that unit of work. Connection poolers that leak session state make this worse — always SET on the txn you own.
Is READ UNCOMMITTED a valid way to make reads fast?
Answer
No on OLTP primaries. You will debug “impossible” values that rolled back. Postgres/InnoDB will not even give you true RU. Use a replica, a snapshot export, or RC. Speed comes from shorter txns and VACUUM keeping versions honest, not dirty reads.
Pitfalls
On paper, draw T1/T2 timelines for dirty, non-repeatable, phantom, and write skew. Mark which ANSI level claims to stop each, then mark what Postgres RR vs InnoDB RR actually does. Write the SQL to start a txn at REPEATABLE READ and at SERIALIZABLE. Sketch a retry loop that restarts from BEGIN on 40001. Optional: list one workload that should stay RC and one that must not.
Go Deeper
- PostgreSQL — Transaction Isolation
- PostgreSQL — Serializable
- MySQL 8.0 — InnoDB isolation
- Cahill, Röhm, Fekete — Serializable Snapshot Isolation
- Wikipedia — Isolation (database systems)
Cluster: MVCC hub · SSI vs SI · FOR UPDATE · VACUUM · Phantoms vs write skew
One-line takeaway: isolation is an engine-specific contract — ANSI names hide SI vs gap locks, and write skew sits outside the phenomena list until SSI, locks, or constraints close it.