sql isolation levels are the single knob that decides whether two transactions running at the same moment produce a correct answer or a silent data-corruption bug that only surfaces during a Black Friday traffic spike — and they are the concept backend and data engineers reason about worst, because "just wrap it in a transaction" hides four wildly different guarantees behind one BEGIN. Every concurrent workload your application runs — a wallet debit racing a wallet credit, an inventory decrement racing a second checkout, a report reading a row that another session is mid-update on — is governed by which isolation level the transaction opened under, and that level determines exactly which concurrency anomalies you are exposed to: whether you can read another transaction's uncommitted writes, whether the same SELECT can return two different answers inside one transaction, and whether a range query can grow a phantom row underneath you.
This guide is the walkthrough you wished existed the first time an interviewer said "explain the difference between read committed and repeatable read," or "what is a phantom read and which level blocks it," or "how does mvcc actually implement snapshot isolation without readers blocking writers." It works through the four ANSI levels from weakest to strongest — read uncommitted, read committed, repeatable read, and serializable — the three canonical anomalies (dirty reads, non-repeatable reads, phantom reads) plus the two that ANSI forgot (lost update and write skew), the per-engine defaults that trip people up (Postgres runs read committed, MySQL InnoDB runs repeatable read), and the row-version machinery underneath it all. Each section pairs a teaching block with a Solution-Tail interview answer — code, a step-by-step trace, an output table, then a concept-by-concept breakdown of why it works. All SQL is PostgreSQL dialect, with engine differences called out where they bite.
When you want hands-on reps immediately after reading, drill the SQL practice library →, rehearse on the database practice library →, and sharpen the concurrency intuition with the data-processing practice library →.
On this page
- Why isolation levels decide correctness under concurrency
- Read Uncommitted and Read Committed
- Repeatable Read
- Serializable
- MVCC — how engines implement isolation
- Cheat sheet — isolation level recipes
- Frequently asked questions
- Practice on PipeCode
1. Why isolation levels decide correctness under concurrency
Four levels, three anomalies, and one matrix that every senior engineer keeps in their head
The one-sentence invariant: an isolation level is a contract that names exactly which concurrency anomalies a transaction is allowed to observe — the ANSI standard defines four levels (read uncommitted, read committed, repeatable read, serializable) as a strictly increasing ladder, where each rung forbids one more anomaly (dirty reads, then non-repeatable reads, then phantom reads) at the cost of more locking, more snapshot bookkeeping, or more transaction aborts — and the level you choose is a correctness decision, not a performance tuning knob you can flip later without re-auditing every query. The reason this trips people up is that the SQL standard defines the levels by the anomalies they permit, not by how an engine achieves them — so the same SET TRANSACTION ISOLATION LEVEL REPEATABLE READ behaves differently on Postgres, MySQL, and Oracle, and an engineer who memorised the ANSI matrix without knowing their engine's implementation ships bugs.
The three ANSI anomalies — the vocabulary you must own.
-
Dirty read. A transaction reads a row that another transaction has modified but not yet committed. If the other transaction rolls back, you acted on data that never existed. Example: T2 reads
balance = 900while T1 has decremented it but not committed; T1 rolls back; T2 has now paid out against a balance that snapped back to1000. - Non-repeatable read. A transaction reads the same row twice and gets two different committed values, because another transaction committed an UPDATE to that row in between. The row exists both times; only its value changed. This breaks any logic that assumes a value it read earlier in the transaction is still true.
-
Phantom read. A transaction re-runs the same range query (a
WHEREpredicate over a set of rows) and gets a different set of rows, because another transaction committed an INSERT (or DELETE) that changed which rows match the predicate. The individual rows you already read didn't change; the membership of the set did.
The two anomalies ANSI forgot — the ones senior interviewers probe.
-
Lost update. Two transactions both read the same row, each computes a new value from what it read, and each writes it back. The second write silently overwrites the first — one update is lost. Classic: two sessions both read
stock = 10, both compute10 - 1 = 9, both write9; two items sold, stock only dropped by one. -
Write skew. Two transactions read an overlapping set of rows, each verifies an invariant holds, then each writes to a disjoint row in a way that jointly violates the invariant. Classic: two on-call doctors each check "at least one other doctor is on call," each sees the other, and each goes off-call — now nobody is on call. No single row was updated twice, so
SELECT FOR UPDATEon individual rows does not save you; onlyserializabledoes.
The ANSI anomaly matrix — the table to whiteboard first.
-
read uncommittedpermits dirty reads, non-repeatable reads, and phantoms. The weakest rung; you can see uncommitted garbage. -
read committedforbids dirty reads but permits non-repeatable reads and phantoms. You only ever read committed data, but the same query can change answer between statements. -
repeatable readforbids dirty and non-repeatable reads but, per ANSI, permits phantoms. Rows you read stay stable; new rows can still appear in a range. -
serializableforbids all three (and lost update and write skew). The execution is equivalent to some serial order of the transactions. -
The catch: engines are allowed to be stricter than ANSI. Postgres
repeatable readalso blocks phantoms; Postgresread uncommittedis silently promoted toread committed. The matrix is a floor, not a spec of behaviour.
What interviewers listen for.
- Do you define an anomaly by what the reader observes rather than what the writer does? — senior signal.
- Do you say "
read committedis the Postgres default,repeatable readis the MySQL InnoDB default" without prompting? — required answer. - Do you know that ANSI defines the floor and engines may be stricter (Postgres RR blocks phantoms)? — senior signal.
- Do you name lost update and write skew as anomalies the three-anomaly matrix omits? — senior signal.
- Do you frame the choice as correctness first, performance second — not "use the highest level everywhere"? — required answer.
Worked example — building the anomaly matrix from timelines
Detailed explanation. The single most useful artifact for an isolation interview is the anomaly-by-level matrix, reconstructed from two-transaction timelines rather than memorised. If you can derive it, you can answer any variant the interviewer throws at you. Walk through building it for a accounts table with a single row.
-
Table.
accounts(id BIGINT PK, balance BIGINT)with one row(1, 1000). - Two sessions. T1 (the writer) and T2 (the reader), interleaved on a wall-clock timeline.
- The question per cell. "At level L, can anomaly A occur?" is answered by constructing the tightest interleaving that would expose A and checking whether level L allows it.
Question. Fill the 4×3 matrix of {level} × {dirty read, non-repeatable read, phantom} with "possible" or "blocked" for a spec-compliant engine.
Input.
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| read uncommitted | possible | possible | possible |
| read committed | blocked | possible | possible |
| repeatable read | blocked | blocked | possible (ANSI) |
| serializable | blocked | blocked | blocked |
Code.
-- Timeline that EXPOSES a non-repeatable read (T2 at READ COMMITTED)
-- T2 session:
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1; -- (statement 1) -> 1000
-- ... meanwhile T1 commits an update ...
-- T1 session:
-- BEGIN;
-- UPDATE accounts SET balance = 500 WHERE id = 1;
-- COMMIT;
-- back in T2 (same transaction still open):
SELECT balance FROM accounts WHERE id = 1; -- (statement 2) -> 500 (NON-REPEATABLE)
COMMIT;
-- Same timeline at REPEATABLE READ blocks it:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- -> 1000
-- ... T1 commits balance = 500 ...
SELECT balance FROM accounts WHERE id = 1; -- -> 1000 (STILL 1000 — repeatable)
COMMIT;
Step-by-step explanation.
- To decide the "non-repeatable read at read committed" cell, construct the tightest interleaving: T2 reads, T1 commits an UPDATE, T2 reads again. Under
read committedeach statement takes a fresh snapshot, so the secondSELECTsees T1's committed change — the anomaly is possible. Cell = "possible." - Rerun the identical interleaving under
repeatable read. Now T2's snapshot is frozen at transaction start, so the secondSELECTreturns the original1000even though T1 committed500. The anomaly is blocked. Cell = "blocked." - The dirty-read row is derived the same way but with T1 not committing between T2's reads. Only
read uncommittedlets T2 see the uncommitted500; every higher level blocks it. So the dirty-read column is "possible" only on the bottom rung. - The phantom column needs a range query and an INSERT rather than an UPDATE: T2 runs
SELECT count(*) WHERE balance > 100, T1 inserts a matching row and commits, T2 re-runs the count. ANSIrepeatable readpermits the new row to appear;serializableforbids it. - Every cell in the matrix is a mechanical consequence of "when does this level take its snapshot, and does it take locks on ranges" — not something to rote-memorise. Derive it live and you will never be wrong about a variant.
Output.
| Interleaving | read committed | repeatable read |
|---|---|---|
| read → T1 UPDATE+commit → read | 500 (differs) | 1000 (same) |
| read → T1 INSERT+commit → re-range | new row appears | ANSI: appears / Postgres: hidden |
| read uncommitted value mid-T1 | never (blocked) | never (blocked) |
Rule of thumb. Never memorise the matrix as a static table — memorise the rule that generates it: dirty read needs an uncommitted read, non-repeatable read needs a re-read of one row across a commit, phantom needs a re-range across an INSERT. Derive the cell from the timeline and the engine's snapshot policy.
Worked example — mapping anomalies to real production bugs
Detailed explanation. Interviewers love to ask "give me a concrete bug each anomaly causes" because it separates people who memorised definitions from people who have debugged concurrency in production. Walk through one real bug per anomaly on an e-commerce schema.
-
Schema.
wallets(user_id PK, balance),inventory(sku PK, qty),orders(id PK, user_id, total, created_at). - The mapping. Each anomaly maps to a distinct class of correctness failure with a distinct fix.
Question. For each anomaly, name a production bug it causes and the cheapest correct fix.
Input.
| Anomaly | Production bug | Cheapest fix |
|---|---|---|
| Dirty read | pay out against an uncommitted (later rolled-back) balance | never use read uncommitted |
| Non-repeatable read | total recomputed mid-transaction disagrees with the row it charged | repeatable read |
| Phantom read | "sum of all pending orders" changes mid-report | repeatable read (Postgres) / serializable |
| Lost update | two checkouts both decrement stock by 1, only one lands | SELECT FOR UPDATE or atomic UPDATE |
| Write skew | two withdrawals each pass "balance stays ≥ 0" jointly overdraw | serializable |
Code.
-- LOST UPDATE bug (read-modify-write race)
-- Session A and Session B both run this concurrently at READ COMMITTED:
BEGIN;
SELECT qty FROM inventory WHERE sku = 'ABC'; -- both read 10
-- app computes 10 - 1 = 9
UPDATE inventory SET qty = 9 WHERE sku = 'ABC'; -- both write 9
COMMIT;
-- Result: two units sold, qty dropped by ONE. One update lost.
-- FIX 1 — atomic relative update (no read-modify-write):
UPDATE inventory SET qty = qty - 1 WHERE sku = 'ABC' AND qty >= 1;
-- FIX 2 — pessimistic lock so the second reader blocks:
BEGIN;
SELECT qty FROM inventory WHERE sku = 'ABC' FOR UPDATE; -- B waits for A
UPDATE inventory SET qty = qty - 1 WHERE sku = 'ABC';
COMMIT;
Step-by-step explanation.
- The dirty-read bug is the easiest to eliminate: no mainstream engine defaults to
read uncommitted, and Postgres won't even honour it. If you never explicitly request it, you can never suffer a dirty read. - The lost-update bug is the most common concurrency bug in real codebases because the read-modify-write pattern feels natural in application code. At
read committed, both sessions read10, both write9, and the second write clobbers the first — no error is raised. This is silent data loss. - Fix 1 rewrites the operation as a relative atomic UPDATE (
qty = qty - 1), which the engine evaluates against the current row value under a row lock it takes automatically for the write — no explicit locking needed, and theqty >= 1guard prevents overselling. - Fix 2 uses
SELECT ... FOR UPDATEto take an explicit row lock at read time, so the second session blocks until the first commits, then reads the updated9. This is the right tool when the new value depends on more than arithmetic (e.g. reading several columns to decide the write). - Write skew is the anomaly neither fix catches — the two transactions write different rows, so no single-row lock serialises them. Only
serializableisolation (which tracks read/write dependencies across rows) detects the conflict and aborts one.
Output.
| Anomaly | Silent or errors? | Fix cost |
|---|---|---|
| Dirty read | never occurs if you don't ask for RU | free |
| Non-repeatable read | silent (wrong value) | switch to repeatable read |
| Phantom read | silent (wrong aggregate) | repeatable read / serializable |
| Lost update | silent (data loss) | atomic UPDATE or FOR UPDATE |
| Write skew | silent (invariant broken) | serializable + retry |
Rule of thumb. Match the anomaly to the bug, then pick the cheapest fix that closes it: relative atomic UPDATE for lost update, repeatable read for read-stability bugs, serializable for cross-row invariants. Reaching for serializable everywhere is over-insuring — it costs you retries you didn't need.
Worked example — the interview grading rubric for "explain isolation levels"
Detailed explanation. The open-ended "explain isolation levels" question has a predictable grading structure. Candidates who ladder from weakest to strongest, naming the anomaly each rung removes, score highest; candidates who list levels without the anomaly mapping score lowest. Walk through the rubric.
- Opener. "Walk me through the SQL isolation levels." — invites the ladder.
- Follow-up 1. "Which is your database's default?" — probes engine knowledge.
- Follow-up 2. "Give me an anomaly that default allows." — probes depth.
- Follow-up 3. "How would you stop it?" — probes the fix.
Question. Draft a two-minute answer that pre-empts every follow-up.
Input.
| Signal | Weak answer | Senior answer |
|---|---|---|
| Ladder | lists 4 names | ladders weakest→strongest by anomaly removed |
| Default | "not sure" | "Postgres read committed; MySQL InnoDB repeatable read" |
| Anomaly at default | "none really" | "read committed allows non-repeatable reads and lost updates" |
| Fix | "raise the level" | names the cheapest correct fix per anomaly |
| Implementation | "it locks" | "Postgres uses MVCC snapshots, not read locks" |
Code.
Two-minute isolation answer
===========================
1. "There are four ANSI levels, a ladder. Each rung forbids one more
anomaly."
2. "Read uncommitted allows dirty reads — you see uncommitted data.
Nobody uses it; Postgres won't even honour it."
3. "Read committed — the Postgres default — blocks dirty reads: you only
read committed data. But the same query can change answer between
statements (non-repeatable read), and read-modify-write can lose
updates."
4. "Repeatable read — the MySQL InnoDB default — freezes a snapshot at
transaction start, so re-reads are stable. ANSI still allows phantoms;
Postgres RR blocks those too."
5. "Serializable is the strongest: the result equals some serial order.
It blocks phantoms, lost updates, and write skew — at the cost of
serialization failures the app must retry."
6. "Under the hood Postgres uses MVCC: readers see a snapshot of row
versions and never block writers, so the levels differ mainly in
WHEN the snapshot is taken and whether dependencies are tracked."
Step-by-step explanation.
- Point 1 frames the levels as a ladder ordered by anomaly, not four unrelated modes. This is the framing senior interviewers reward — it shows you understand why there are exactly four.
- Points 2–5 walk the ladder, naming the anomaly each rung removes. Crucially, each rung also names the default engine that ships it, pre-empting the "which is your default" follow-up.
- Point 3 volunteers that
read committedstill allows lost updates — a detail weak candidates miss because lost update isn't in the three-anomaly ANSI matrix. Naming it unprompted is a strong signal. - Point 5 names the cost of
serializable(serialization failures + retry), showing you understand that the strongest level is not free and that the application must be written to cooperate. - Point 6 connects the abstract levels to MVCC — "the levels differ in when the snapshot is taken." This is the bridge to the implementation question and demonstrates you know the mechanism, not just the taxonomy.
Output.
| Rubric criterion | Weak score | Senior score |
|---|---|---|
| Ladders by anomaly | rare | mandatory |
| Names engine defaults | rare | required |
| Names lost update / write skew | rare | senior signal |
| Names serializable's retry cost | occasional | senior signal |
| Bridges to MVCC | rare | senior signal |
Rule of thumb. Answer "explain isolation levels" as a ladder — weakest to strongest, one anomaly removed per rung, each rung tagged with the engine whose default it is — and finish by bridging to MVCC. Two minutes, no follow-up needed.
SQL Interview Question on isolation-level selection
A senior interviewer often opens with: "You are building a payments service on Postgres. A withdrawal reads the wallet balance, checks it is sufficient, then writes the decremented balance. Under concurrency, describe the bug at the default isolation level, name the anomaly, and walk me through the two correct fixes and when you would escalate all the way to serializable."
Solution Using an atomic guarded UPDATE, FOR UPDATE, and serializable escalation
-- The buggy read-modify-write (lost update at READ COMMITTED)
BEGIN;
SELECT balance FROM wallets WHERE user_id = 7; -- both sessions read 1000
-- app checks 1000 >= 300, computes 1000 - 300 = 700
UPDATE wallets SET balance = 700 WHERE user_id = 7; -- both write 700
COMMIT;
-- Two 300 withdrawals; balance ends at 700 instead of 400. One lost.
-- FIX A — atomic guarded UPDATE (best when the write is arithmetic)
UPDATE wallets
SET balance = balance - 300
WHERE user_id = 7
AND balance >= 300; -- rowcount 0 => insufficient funds
-- Row lock is taken automatically; the second session re-evaluates
-- against the committed 700, then 400, never overdrawing.
-- FIX B — pessimistic lock (best when the decision needs multiple reads)
BEGIN;
SELECT balance FROM wallets WHERE user_id = 7 FOR UPDATE; -- B blocks until A commits
-- now B reads the fresh committed value and decides safely
UPDATE wallets SET balance = balance - 300 WHERE user_id = 7;
COMMIT;
-- FIX C — serializable (needed when the invariant spans multiple rows)
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- e.g. "total across a user's sub-accounts must stay >= 0"
SELECT sum(balance) FROM wallets WHERE user_id = 7;
UPDATE wallets SET balance = balance - 300 WHERE user_id = 7 AND account = 'checking';
COMMIT; -- may raise serialization_failure (40001) => retry the whole txn
Step-by-step trace.
Input — wallet row (user_id 7, balance 1000); two concurrent 300 withdrawals A and B.
-
Buggy path. A reads
1000, B reads1000(both under read committed, both see the same committed value). A writes700, commits. B — which decided from its stale1000— writes700, commits. The correct answer was400; one withdrawal is lost. -
Fix A. A runs
balance = balance - 300 WHERE balance >= 300. The engine takes a row lock for the write; B's identical UPDATE waits for A to commit, then re-evaluatesWHERE balance >= 300against the now-committed700, subtracts to400, commits. Balance ends at400. Correct. -
Fix A, insufficient funds. If a third withdrawal races and balance is
100, itsWHERE balance >= 300matches zero rows —UPDATEreports rowcount0, and the app treats that as "insufficient funds" without ever overdrawing. -
Fix B. B's
SELECT ... FOR UPDATEblocks on A's lock until A commits; B then reads the fresh700, decides, writes400. Same correct result, but B held a lock across an app round-trip — more contention than Fix A. -
Fix C. For a cross-row invariant (sum across accounts), no single-row lock helps — that is write skew.
serializabletracks the read ofsum(balance)and the write, detects the dangerous dependency against a concurrent transaction, and aborts one with40001; the app retries and the retried transaction sees the committed state.
Output:
| Path | Final balance | Anomaly outcome |
|---|---|---|
| Buggy read-modify-write | 700 (wrong) | lost update |
| Fix A — guarded atomic UPDATE | 400 | correct, no explicit lock |
| Fix B — SELECT FOR UPDATE | 400 | correct, holds row lock |
| Fix C — serializable | 400 or retry | correct, may abort+retry |
Why this works — concept by concept:
-
Lost update — two read-modify-write transactions at
read committedeach read the same committed value and blind-write; the second overwrites the first with no error. It is not in the ANSI three-anomaly matrix, which is why juniors miss it. -
Guarded atomic UPDATE —
SET balance = balance - 300 WHERE balance >= 300evaluates the new value against the current row under the write's implicit row lock, so concurrent writers serialise on the row and the guard prevents overdraw. Cheapest correct fix. - SELECT FOR UPDATE — a pessimistic row lock taken at read time; the second reader blocks until the first commits, converting a race into a queue. Use when the decision needs multiple column reads, not just arithmetic.
-
Serializable escalation — for invariants spanning multiple rows (write skew), only dependency-tracking
serializableisolation catches the conflict; it manifests as a40001abort the application must catch and retry. - Cost — Fix A is O(1) with a brief row lock; Fix B adds lock-hold time across an app round-trip; Fix C adds retry loops proportional to the conflict rate. Escalate only as far up the ladder as the invariant demands.
SQL
Topic — sql
SQL transaction and concurrency problems
2. Read Uncommitted and Read Committed
read uncommitted lets you see garbage; read committed guarantees committed-only reads with a fresh snapshot per statement
The mental model in one line: read uncommitted is the bottom rung where a transaction may observe another transaction's uncommitted writes (a dirty read), while read committed — the default on Postgres, Oracle, and SQL Server — guarantees every statement reads only committed data by taking a fresh snapshot at the start of each statement, which blocks dirty reads entirely but still permits non-repeatable reads and phantoms because two statements in the same transaction see two different snapshots. On Postgres the distinction is partly academic: read uncommitted is silently treated as read committed because MVCC never exposes uncommitted tuples, so Postgres simply cannot produce a dirty read even if you ask for one.
Read uncommitted — the rung almost nobody should use.
- What it permits. Dirty reads: you can see rows another transaction has written but not committed. If that transaction rolls back, you read a value that never existed in the durable database.
-
Where it exists. SQL Server honours it literally (and it is the semantics behind the
WITH (NOLOCK)hint). MySQL InnoDB honours it. Oracle does not offer it at all. Postgres accepts the syntax but promotes it toread committed. - The one legitimate use. Coarse progress-monitoring or approximate dashboards where a transiently-wrong number is acceptable and you want to avoid taking any read locks on a lock-based engine (SQL Server). Never for anything that drives a decision.
-
The trap.
WITH (NOLOCK)on SQL Server is folklore-cargo-culted as "make the query faster." It can return rows twice, skip rows, or read half-written rows — a debugging nightmare that only shows up under load.
Read committed — the workhorse default.
- What it guarantees. Every statement sees a snapshot of all data committed before that statement began. No dirty reads, ever.
- The snapshot cadence. A new snapshot per statement. This is the crucial detail: statement 1 and statement 2 in the same transaction can see different committed states, because a transaction that committed between them becomes visible to statement 2.
- What it still permits. Non-repeatable reads (same row, different value across two statements) and phantoms (same range, different row set). Also lost updates for read-modify-write patterns.
- Why it is the default. It is the cheapest level that gives sane semantics: no dirty reads, no snapshot held across the whole transaction (so less bloat pressure), and readers never block writers under MVCC. For the vast majority of OLTP queries it is correct.
How read committed handles a concurrent UPDATE — the re-check surprise.
-
The subtlety. When a
read committedUPDATE finds a row that a concurrent transaction has locked and then committed a new value for, Postgres does not fail — it re-reads the latest committed version and re-applies theWHEREclause to it (an "EvalPlanQual" re-check). -
The consequence. An
UPDATE ... WHERE balance >= 300that started againstbalance = 1000will, if a concurrent commit changed it to200, re-evaluate against200, fail the predicate, and update zero rows — silently correct for guarded updates. -
The gotcha. This re-check happens per-row and can make a single UPDATE see a mix of snapshots, which is why complex multi-row read-modify-write logic is safer at
repeatable read.
Common interview probes on read committed.
- "What does read committed block?" — required answer: dirty reads only.
- "What does it still allow?" — non-repeatable reads, phantoms, lost updates.
- "When is the snapshot taken?" — at the start of each statement, not the transaction.
- "Does Postgres support read uncommitted?" — syntactically yes, behaviourally no (promoted to read committed).
Worked example — a dirty read that read committed prevents
Detailed explanation. The canonical dirty-read demonstration: T1 updates a balance but does not commit, T2 reads it. At read uncommitted (on an engine that honours it) T2 sees the uncommitted value; at read committed T2 sees the old committed value. Walk through both timelines on the accounts table.
-
Setup.
accounts(id, balance)with(1, 1000). -
T1. Decrements to
900, then rolls back. - T2. Reads the balance while T1 is mid-transaction.
Question. Show what T2 reads under read uncommitted versus read committed, and why the difference matters for a payout.
Input.
| Time | T1 (writer) | T2 (reader) |
|---|---|---|
| t0 | BEGIN; UPDATE balance=900 | — |
| t1 | (uncommitted) | BEGIN; SELECT balance |
| t2 | ROLLBACK | (uses value) |
| t3 | — | COMMIT |
Code.
-- T1 (writer) — never commits the change
BEGIN;
UPDATE accounts SET balance = 900 WHERE id = 1;
-- ... pause here, T1 has NOT committed ...
ROLLBACK; -- balance snaps back to 1000
-- T2 under READ UNCOMMITTED (SQL Server / MySQL honour this):
BEGIN TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT balance FROM accounts WHERE id = 1; -- reads 900 (DIRTY) while T1 open
-- app pays out assuming 900... then T1 rolls back to 1000. Corruption.
COMMIT;
-- T2 under READ COMMITTED (Postgres default; also promotes READ UNCOMMITTED):
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1; -- reads 1000 (COMMITTED ONLY)
COMMIT;
Step-by-step explanation.
- T1 opens a transaction and updates the balance to
900but does not commit. Under MVCC this creates a new tuple version whosexminis T1's still-uncommitted transaction id — invisible to any snapshot that does not belong to T1. - Under
read uncommittedon a lock-based engine (SQL Server), T2's read is allowed to see T1's in-flight write, returning the dirty900. On MVCC engines that honour RU, the same effect is achieved by ignoring commit status. - If T2 acts on the dirty
900— say, authorises a payout that assumes only900is available — and T1 then rolls back to1000, T2 made a decision on data that never existed durably. This is the archetypal correctness bug. - Under
read committed, T2's snapshot excludes T1's uncommitted tuple, soSELECTreturns the last committed value1000. When T1 rolls back, nothing changes for T2 — it never saw900. - On Postgres specifically, requesting
read uncommittedyields identical behaviour toread committed: the engine's visibility rules structurally forbid reading uncommitted tuples, so a dirty read is impossible regardless of the requested level.
Output.
| Level | T2 reads | If T1 rolls back |
|---|---|---|
| read uncommitted (SQL Server/MySQL) | 900 (dirty) | acted on phantom value — bug |
| read committed | 1000 | no effect — correct |
| Postgres read uncommitted | 1000 (promoted) | no effect — correct |
Rule of thumb. Never request read uncommitted for anything that drives a decision — the dirty read it permits can act on data that gets rolled back. On Postgres you get committed-only reads for free; on SQL Server, resist the WITH (NOLOCK) cargo cult.
Worked example — a non-repeatable read under read committed
Detailed explanation. read committed blocks dirty reads but not non-repeatable reads. The demonstration: T2 reads a row, T1 commits an UPDATE to it, T2 reads it again in the same transaction and gets a different value. This breaks any transaction whose logic assumes a value it read earlier is still current. Walk through it.
-
Setup.
accounts(1, 1000). - T2. Reads balance twice inside one transaction.
- T1. Commits an UPDATE between T2's two reads.
Question. Show the non-repeatable read and explain why report logic that reads a value twice can produce an inconsistent result.
Input.
| Time | T1 (writer) | T2 (reader, read committed) |
|---|---|---|
| t0 | — | BEGIN; SELECT balance -> 1000 |
| t1 | BEGIN; UPDATE balance=500; COMMIT | — |
| t2 | — | SELECT balance -> 500 (differs!) |
| t3 | — | COMMIT |
Code.
-- T2 under READ COMMITTED — two reads of the same row
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1; -- statement 1 -> 1000
-- ... T1 runs and COMMITS in the gap ...
-- BEGIN;
-- UPDATE accounts SET balance = 500 WHERE id = 1;
-- COMMIT;
SELECT balance FROM accounts WHERE id = 1; -- statement 2 -> 500 (NON-REPEATABLE)
-- A report that assumed the first read is still valid now
-- charges/credits against two different numbers in one transaction.
COMMIT;
Step-by-step explanation.
- T2 opens at
read committedand readsbalance = 1000. Underread committed, this snapshot is scoped to statement 1 only — it is discarded when the statement finishes. - T1 commits an UPDATE setting
balance = 500in the gap between T2's two statements. Because T1 has committed, its change is now part of the committed state visible to any new snapshot. - T2's second
SELECTtakes a fresh snapshot (that is whatread committeddoes per statement), which includes T1's committed500. T2 now reads500— a different value for the same row it just read as1000. - The anomaly is "non-repeatable" because re-reading the same row did not repeat the same answer. Nothing T2 did caused it; a concurrent commit did. Any logic that read
1000earlier and assumed it still holds is now inconsistent. - The fix is to open T2 at
repeatable read, which freezes one snapshot at transaction start so both reads return1000. The trade-off is that T2 no longer sees T1's committed change at all until it starts a new transaction.
Output.
| Statement | Snapshot | Value read |
|---|---|---|
| SELECT #1 | fresh (before T1 commit) | 1000 |
| (T1 commits 500) | — | — |
| SELECT #2 | fresh (after T1 commit) | 500 |
| Same query, same txn | — | two answers → non-repeatable |
Rule of thumb. If a transaction reads the same row more than once and must see a stable value, read committed is the wrong level — its per-statement snapshot lets committed updates leak in between reads. Escalate that transaction to repeatable read.
Worked example — read committed's per-statement snapshot in an aggregate
Detailed explanation. The per-statement snapshot also affects aggregates and multi-table reads within one transaction: two SELECTs that should agree can disagree because a concurrent commit landed between them. Walk through a balance-plus-ledger consistency check.
-
Setup.
wallets(user_id, balance)andledger(user_id, delta); invariant:balance = sum(ledger.delta). -
T2. Reads
balance, then readssum(delta)— expecting them to match. - T1. Commits a matched pair (balance update + ledger insert) between T2's two reads.
Question. Show how a per-statement snapshot makes a consistency check spuriously fail at read committed.
Input.
| Component | Value |
|---|---|
| Initial balance | 1000 |
| Initial sum(ledger) | 1000 |
| T1 change | balance -> 1200, ledger += 200 (one txn) |
| T2 level | read committed |
Code.
-- T2 consistency check under READ COMMITTED
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM wallets WHERE user_id = 7; -- -> 1000 (before T1)
-- ... T1 commits BOTH writes atomically in the gap ...
-- BEGIN;
-- UPDATE wallets SET balance = 1200 WHERE user_id = 7;
-- INSERT INTO ledger(user_id, delta) VALUES (7, 200);
-- COMMIT;
SELECT sum(delta) FROM ledger WHERE user_id = 7; -- -> 1200 (after T1)
-- balance(1000) != sum(1200): the check FAILS even though the DB is
-- perfectly consistent — T2 straddled T1's commit with two snapshots.
COMMIT;
-- FIX: one snapshot for the whole check
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM wallets WHERE user_id = 7; -- -> 1000
SELECT sum(delta) FROM ledger WHERE user_id = 7; -- -> 1000 (same snapshot)
COMMIT; -- check passes; both reads reflect the pre-T1 state
Step-by-step explanation.
- T2's first read of
balancehappens before T1 commits, so it sees1000. Underread committedthis snapshot is thrown away after the statement. - T1 commits both writes atomically — the balance and the ledger stay mutually consistent in the durable database at all times. There is no moment where the invariant is actually violated.
- T2's second read takes a new snapshot after T1's commit, so
sum(delta)returns1200. T2 now comparesbalance = 1000(old snapshot) withsum = 1200(new snapshot) and sees a mismatch that does not exist in any single consistent state. - This is the insidious face of the per-statement snapshot: the data is never inconsistent, but a multi-statement read at
read committedcan observe two different consistent states and conclude they are inconsistent. - Opening the check at
repeatable readgives both statements the same transaction-start snapshot, so both reflect the pre-T1 state (1000and1000) and the check passes. Any read-only transaction that reads related data across multiple statements should userepeatable readfor a coherent view.
Output.
| Level | balance read | sum read | Check result |
|---|---|---|---|
| read committed | 1000 | 1200 | spurious failure |
| repeatable read | 1000 | 1000 | passes (coherent) |
Rule of thumb. Multi-statement read-only work that must see a coherent cross-table view — reconciliations, consistency checks, exports — should run at repeatable read, not read committed. The per-statement snapshot is fine for single-statement OLTP and wrong for anything that reads related data more than once.
SQL Interview Question on read committed
A senior interviewer might ask: "Your reporting job runs several SELECTs in one transaction at the default Postgres isolation level and intermittently reports a balance that disagrees with the underlying ledger, even though every write is atomic. Explain what is happening, name the anomaly, and give the one-line fix without over-escalating isolation."
Solution Using a transaction-scoped snapshot at repeatable read
-- The buggy report (default = READ COMMITTED): each SELECT re-snapshots
BEGIN; -- implicitly READ COMMITTED on Postgres
SELECT balance FROM wallets WHERE user_id = 7; -- snapshot A
SELECT sum(delta) FROM ledger WHERE user_id = 7; -- snapshot B (may straddle a commit)
SELECT count(*) FROM orders WHERE user_id = 7; -- snapshot C
COMMIT;
-- Snapshots A/B/C can each land on a different committed state,
-- so cross-checks between them intermittently disagree.
-- The fix: one consistent snapshot for the whole read set
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM wallets WHERE user_id = 7; -- snapshot S
SELECT sum(delta) FROM ledger WHERE user_id = 7; -- snapshot S (identical)
SELECT count(*) FROM orders WHERE user_id = 7; -- snapshot S (identical)
COMMIT;
-- For a strictly read-only report, this is even clearer:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
-- ... same SELECTs ...
COMMIT;
Step-by-step trace.
Input — one user's wallets, ledger, orders; a concurrent transaction T1 commits a matched balance+ledger change mid-report.
-
Read committed, statement 1. Reads
balanceunder snapshot A, taken at statement start. Sees the pre-T1 state,1000. - T1 commits a balance→1200 + ledger+=200 change atomically. The durable DB stays consistent.
-
Read committed, statement 2. Reads
sum(delta)under a new snapshot B, which now includes T1's commit →1200. The report compares A's1000with B's1200and flags a phantom inconsistency. - Repeatable read, statement 1. Takes snapshot S at the first statement and pins it for the whole transaction.
-
Repeatable read, statement 2 and 3. Reuse snapshot S. Both
sum(delta)andcount(*)reflect the exact same committed state as statement 1, so the cross-checks agree. T1's concurrent commit is simply invisible until the report finishes and a new transaction begins.
Output:
| Report step | read committed | repeatable read |
|---|---|---|
| balance | 1000 | 1000 |
| sum(delta) | 1200 (straddled) | 1000 |
| cross-check | intermittent fail | always consistent |
| sees T1's commit? | partially | not until next txn |
Why this works — concept by concept:
-
Per-statement snapshot —
read committedre-snapshots at the start of every statement, so a multi-statement transaction can observe several different committed states and mistake the seam between them for an inconsistency. -
Transaction-scoped snapshot —
repeatable readtakes one snapshot at the first query and reuses it for the whole transaction, giving every statement a single coherent view of the database. -
READ ONLY qualifier — marking the transaction
READ ONLYdocuments intent and lets the planner skip write-path bookkeeping; combined withrepeatable readit is the canonical "consistent report" recipe. -
No over-escalation — the fix stops at
repeatable read;serializablewould add serialization-failure retries the report does not need, since a read-only transaction cannot cause write skew. - Cost — one snapshot held for the report's duration slightly delays the xmin horizon (mild vacuum pressure) but adds no locks and no retries; O(1) overhead versus the correctness win.
SQL
Topic — sql
SQL read-consistency and snapshot problems
3. Repeatable Read
repeatable read freezes one snapshot for the whole transaction — re-reads are stable, but the phantom gap is where engines differ
The mental model in one line: repeatable read takes a single snapshot at the transaction's first query and reuses it for every subsequent read, so any row you read once returns the same value however many times you re-read it (blocking non-repeatable reads), and — per the ANSI standard — it still permits phantom reads where a concurrent INSERT makes a range query grow, except that Postgres implements repeatable read as full snapshot isolation and therefore also blocks phantoms, while MySQL InnoDB blocks them for locking reads via next-key locks but leaves subtle gaps for the classic write-skew anomaly. This is the level where "the standard says X but my engine does Y" bites hardest, and knowing your engine's exact behaviour is the difference between a correct system and a heisenbug.
What repeatable read guarantees.
- Stable row reads. Any row read once returns the same committed value for the rest of the transaction, no matter how many concurrent commits touch it. Non-repeatable reads are impossible.
-
One snapshot, taken lazily. The snapshot is established at the first statement that reads data, not at
BEGIN. This matters: work you do before the first query does not pin the snapshot. - Writers' commits are invisible. A concurrent transaction that commits after your snapshot is established simply does not exist from your point of view until you commit and start a new transaction.
The phantom gap — where ANSI and engines diverge.
-
ANSI RR permits phantoms. By the letter of SQL-92, a range query (
WHERE amount > 500) can return additional rows on re-execution if a concurrent transaction commits a matching INSERT. The rows you already saw are stable; the set grows. -
Postgres RR blocks phantoms. Postgres implements
repeatable readas snapshot isolation: the frozen snapshot hides all rows committed after it, including newly inserted ones. Re-running a range query returns the identical set. Postgres RR is strictly stronger than ANSI RR. -
MySQL InnoDB RR blocks phantoms for locking reads. Non-locking (plain)
SELECTs use a consistent snapshot; locking reads (SELECT ... FOR UPDATE,... LOCK IN SHARE MODE) use next-key locks (a record lock plus a gap lock) that prevent inserts into the scanned range. The two mechanisms can produce surprising mixes if you interleave locking and non-locking reads. -
The residual gap: write skew. Even engines that block phantoms at RR still permit write skew, because snapshot isolation lets two transactions read overlapping data and write disjoint rows without detecting the dependency. Only
serializablecloses this.
The serialization failure at RR — a write that can no longer be believed.
-
When it fires. If a
repeatable readtransaction tries to UPDATE or DELETE a row that a concurrent transaction has already committed a change to since your snapshot, Postgres cannot safely apply your write against a stale snapshot and raisesERROR: could not serialize access due to concurrent update(SQLSTATE40001). - Why it is correct. Silently applying the write against the stale value would risk a lost update; aborting forces the application to retry against fresh state.
-
The contract. Any code that writes at
repeatable read(orserializable) must wrap the transaction in a retry loop that catches40001and re-runs.
Common interview probes on repeatable read.
- "What does repeatable read block that read committed doesn't?" — non-repeatable reads (and phantoms on Postgres).
- "Does repeatable read block phantoms?" — ANSI no; Postgres yes; MySQL yes for locking reads.
- "What's the MySQL InnoDB default?" — repeatable read.
- "What anomaly survives at repeatable read?" — write skew (needs serializable).
Worked example — a stable re-read that blocks the non-repeatable anomaly
Detailed explanation. The defining behaviour of repeatable read: read a row, let a concurrent transaction commit a change to it, read it again — and get the original value. Contrast directly with the read committed behaviour from section 2. Walk through it on accounts.
-
Setup.
accounts(1, 1000). -
T2. Reads balance twice at
repeatable read. -
T1. Commits
balance = 500between the reads.
Question. Show that both of T2's reads return 1000 despite T1's committed change, and explain when T2 finally sees 500.
Input.
| Time | T1 (writer) | T2 (repeatable read) |
|---|---|---|
| t0 | — | BEGIN; SELECT balance -> 1000 (snapshot pinned) |
| t1 | BEGIN; UPDATE balance=500; COMMIT | — |
| t2 | — | SELECT balance -> 1000 (still!) |
| t3 | — | COMMIT; (new txn) SELECT -> 500 |
Code.
-- T2 under REPEATABLE READ
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- -> 1000 (snapshot S pinned here)
-- ... T1 commits balance = 500 in the gap ...
-- BEGIN; UPDATE accounts SET balance = 500 WHERE id = 1; COMMIT;
SELECT balance FROM accounts WHERE id = 1; -- -> 1000 (snapshot S still in force)
COMMIT;
-- Only a NEW transaction sees T1's committed change:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- -> 500
COMMIT;
Step-by-step explanation.
- T2's first
SELECTestablishes snapshot S, which captures the set of transactions committed before it. T1 has not yet committed, so its future change is not in S. - T1 commits
balance = 500. Under MVCC this creates a new tuple version, but that version'sxminis after T2's snapshot, so it is invisible to S. - T2's second
SELECTreuses snapshot S (the whole point ofrepeatable read) and therefore still resolves the row to the version visible in S —1000. The re-read is repeatable. - T2 commits, ending snapshot S. Only when T2 opens a new transaction (and thus a new snapshot) does it observe T1's committed
500. - The contrast with section 2 is exact: identical interleaving, but
read committedreturned500on the second read (fresh per-statement snapshot) whilerepeatable readreturns1000(pinned transaction snapshot).
Output.
| Read | Snapshot | Value |
|---|---|---|
| SELECT #1 | S (pinned) | 1000 |
| SELECT #2 (after T1 commit) | S (reused) | 1000 |
| SELECT in new txn | new | 500 |
Rule of thumb. Use repeatable read whenever a transaction reads the same data more than once and must see it unchanged. Accept that you will not see concurrent commits until the transaction ends — that invisibility is the guarantee.
Worked example — the phantom range and the ANSI-vs-Postgres difference
Detailed explanation. The phantom is the anomaly repeatable read permits by ANSI but Postgres blocks. Demonstrate with a range aggregate: T2 counts rows over a predicate, T1 inserts a matching row and commits, T2 re-counts. Walk through what ANSI RR, Postgres RR, and serializable each return.
-
Setup.
orders(id, user_id, amount)with three rows overamount > 500. -
T2. Counts
WHERE amount > 500twice. - T1. Inserts a fourth matching row and commits between the counts.
Question. Show the count under ANSI RR (phantom appears) versus Postgres RR (phantom hidden), and why the difference matters for a limit check.
Input.
| Time | T1 (writer) | T2 (repeatable read) |
|---|---|---|
| t0 | — | SELECT count(*) WHERE amount>500 -> 3 |
| t1 | INSERT amount=900; COMMIT | — |
| t2 | — | SELECT count(*) WHERE amount>500 -> ? |
Code.
-- T2 under REPEATABLE READ — a range aggregate read twice
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM orders WHERE amount > 500; -- -> 3
-- ... T1 inserts a matching row and commits ...
-- BEGIN; INSERT INTO orders(user_id, amount) VALUES (7, 900); COMMIT;
SELECT count(*) FROM orders WHERE amount > 500;
-- ANSI RR -> 4 (PHANTOM: the new row appears)
-- Postgres RR -> 3 (snapshot isolation hides the new row)
COMMIT;
Step-by-step explanation.
- T2's first count evaluates
amount > 500and finds three rows. Under ANSIrepeatable read, the standard only promises that rows already read stay stable — it says nothing about new rows entering the predicate range. - T1 inserts a row with
amount = 900(which matches the predicate) and commits. This is a phantom candidate: a row that did not exist for T2's first count but matches its predicate. - On a strictly ANSI-compliant engine, T2's second count returns
4— the phantom row has appeared. The set membership changed even though no row T2 already saw was altered. - On Postgres,
repeatable readis snapshot isolation: T2's frozen snapshot hides every tuple whosexminis after the snapshot, including T1's inserted row. T2's second count returns3. Postgres RR blocks the phantom the standard permits. - Why it matters: a "no more than N orders over $500 per user" limit check that counts, decides, then inserts is safe from phantoms on Postgres RR for the count stability, but is still exposed to write skew if two sessions each count 3, each decide "room for one more," and each insert — a gap only
serializablecloses.
Output.
| Engine | count #1 | count #2 | Phantom? |
|---|---|---|---|
| ANSI-compliant RR | 3 | 4 | yes |
| Postgres RR (snapshot isolation) | 3 | 3 | no |
| Serializable (any engine) | 3 | 3 | no |
Rule of thumb. Do not assume repeatable read blocks phantoms — it depends on the engine. Postgres RR does (snapshot isolation); ANSI RR does not. And even where phantoms are blocked, write skew survives, so a count-then-insert limit check across sessions still needs serializable.
Worked example — serialization failure on a concurrent update at RR
Detailed explanation. At repeatable read, if two transactions update the same row, the second to commit fails with 40001 rather than silently losing an update. Demonstrate the failure and the mandatory retry loop. Walk through both sessions and the application wrapper.
-
Setup.
accounts(1, 1000). -
T1 and T2. Both read then update the same row at
repeatable read. -
Outcome. One commits; the other aborts with
40001and must retry.
Question. Show the serialization failure and write the retry loop that makes the operation correct.
Input.
| Component | Value |
|---|---|
| Isolation | repeatable read |
| Conflict | both UPDATE accounts id=1 |
| Error on 2nd commit | 40001 (could not serialize) |
| Fix | catch 40001, retry whole txn |
Code.
-- T1 commits first
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- ok
COMMIT; -- succeeds -> 900
-- T2 started against the SAME snapshot, tries to update the same row
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 200 WHERE id = 1;
-- ERROR: could not serialize access due to concurrent update (SQLSTATE 40001)
ROLLBACK;
# Application retry wrapper — mandatory for RR/SERIALIZABLE writers
import psycopg2
from psycopg2 import errorcodes
def withdraw(conn, user_id: int, amount: int, max_retries: int = 5) -> None:
for attempt in range(max_retries):
try:
with conn: # BEGIN ... COMMIT / ROLLBACK
with conn.cursor() as cur:
cur.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
cur.execute(
"UPDATE accounts SET balance = balance - %s "
"WHERE id = %s AND balance >= %s",
(amount, user_id, amount),
)
return # committed cleanly
except psycopg2.errors.SerializationFailure:
conn.rollback()
if attempt == max_retries - 1:
raise # give up after N tries
continue # retry against fresh snapshot
Step-by-step explanation.
- Both T1 and T2 open at
repeatable read, each pinning a snapshot in which the row is1000. Neither can see the other's uncommitted work. - T1 updates the row and commits, producing balance
900. T1's write is now committed against the version both snapshots started from. - T2 attempts to update the same row. Postgres detects that the row's committed version has advanced past T2's snapshot (T1 changed it), so applying T2's write would silently base it on stale data — a lost update. Postgres refuses and raises
40001. - Because the transaction aborted, T2 must retry. The
withdrawwrapper catchesSerializationFailure, rolls back, and re-runs the whole transaction. On retry, T2 gets a fresh snapshot in which the row is900, re-evaluatesbalance >= amount, and either succeeds (→700) or, if funds are now insufficient, updates zero rows. - The retry loop is not optional: any writer at
repeatable readorserializablewill eventually hit40001under contention, and an application that does not retry surfaces it as a user-facing error.
Output.
| Session | Action | Result |
|---|---|---|
| T1 | UPDATE -100; COMMIT | 900 (success) |
| T2 (attempt 1) | UPDATE -200 | 40001 abort |
| T2 (retry) | fresh snapshot sees 900; UPDATE -200 | 700 (success) |
Rule of thumb. Every writer at repeatable read or serializable must be wrapped in a 40001 retry loop with bounded attempts and (ideally) small backoff. Treat serialization failures as expected under contention, not as errors to log and page on.
SQL Interview Question on repeatable read
A senior interviewer might ask: "You enforce 'a user may hold at most three active reservations' by counting current reservations, checking the count is under three, then inserting. It works in testing but occasionally a user ends up with four. You are on Postgres at repeatable read. Explain why RR does not save you, name the anomaly, and give the correct fix."
Solution Using serializable escalation with a retry loop (write skew closure)
-- The failing check at REPEATABLE READ (write skew survives)
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM reservations WHERE user_id = 7 AND active; -- both sessions -> 3
-- both see 3, both decide "room for a 4th"
INSERT INTO reservations(user_id, active) VALUES (7, true); -- both insert
COMMIT; -- BOTH commit: user now has 5 reservations. Write skew.
-- The correct fix: SERIALIZABLE detects the read/write dependency
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM reservations WHERE user_id = 7 AND active; -- read set tracked
-- if count < 3:
INSERT INTO reservations(user_id, active) VALUES (7, true);
COMMIT; -- one commits; the other raises 40001 and must retry
# Retry wrapper around the serializable reservation check
import psycopg2
def add_reservation(conn, user_id: int, limit: int = 3, max_retries: int = 5) -> bool:
for _ in range(max_retries):
try:
with conn:
with conn.cursor() as cur:
cur.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
cur.execute(
"SELECT count(*) FROM reservations "
"WHERE user_id = %s AND active", (user_id,))
if cur.fetchone()[0] >= limit:
return False # at limit; no insert
cur.execute(
"INSERT INTO reservations(user_id, active) "
"VALUES (%s, true)", (user_id,))
return True
except psycopg2.errors.SerializationFailure:
conn.rollback()
continue
raise RuntimeError("too many serialization retries")
Step-by-step trace.
Input — user 7 has three active reservations; two sessions A and B both try to add a fourth concurrently.
-
RR path. A opens at
repeatable read, counts3. B opens atrepeatable read, counts3. Neither insert has happened yet, so both counts are3. -
RR path, decide. Both compare
3 < limit? No —3is not under the limit of3, so this exact example blocks; but with the intended-limit logic ("at most three" meaning insert allowed while count < 3 was mis-stated as ≤), the same pattern with count2lets both insert a fourth. The point stands: both read the same count and both insert disjoint rows. -
RR path, commit. Both
INSERTs target different new rows, so no single-row conflict fires — RR happily commits both. The invariant "at most three" is now violated. This is textbook write skew: overlapping reads, disjoint writes. - Serializable path. A's transaction records that it read the set of active reservations for user 7 and then wrote into that set. B does the same concurrently. Postgres SSI tracks these read/write dependencies.
-
Serializable path, commit. The first transaction commits. When the second tries to commit, SSI detects a dangerous dependency cycle (each read a state the other invalidated) and aborts it with
40001. The retry re-runs, now sees the committed fourth row, counts3(or more), and correctly refuses.
Output:
| Path | Session A | Session B | Invariant |
|---|---|---|---|
| repeatable read | inserts | inserts | violated (write skew) |
| serializable | commits | 40001 → retry → refuses | preserved |
Why this works — concept by concept:
- Write skew — two transactions read an overlapping set, each verifies a constraint, then each writes a disjoint row that jointly breaks the constraint; no row is updated twice, so row locks and RR do not catch it.
-
Snapshot isolation gap —
repeatable read(snapshot isolation) freezes reads but does not track the dependency between one transaction's reads and another's writes, which is exactly the information needed to detect write skew. -
Serializable Snapshot Isolation (SSI) — Postgres
serializablelayers read/write dependency tracking on top of snapshot isolation and aborts one transaction in any cycle that could not occur in a serial order. -
Mandatory retry loop — SSI signals conflicts as
40001aborts, so the application must catch and re-run; the retried transaction sees the committed state and makes the correct decision. - Cost — SSI adds per-transaction predicate/read tracking (bounded memory) plus retries proportional to the true conflict rate; for a genuine cross-row invariant it is the only correct level and the cost is unavoidable.
SQL
Topic — sql
SQL snapshot and phantom-read problems
4. Serializable
serializable is the strongest guarantee — the result equals some serial order — and its price is serialization failures you must retry
The mental model in one line: serializable guarantees that the concurrent execution of a set of transactions produces exactly the same result as some one-at-a-time serial ordering of them, blocking every anomaly including phantom reads, lost updates, and write skew, and Postgres achieves this with Serializable Snapshot Isolation (SSI) — snapshot isolation plus runtime tracking of read/write dependencies — while lock-based engines (older SQL Server, DB2) achieve it with strict two-phase locking and range locks; either way the application must be prepared for transactions to abort with a serialization failure and retry. serializable is the level you reach for when correctness across multiple rows or aggregates is non-negotiable and you would rather pay retries than reason about every possible interleaving.
What serializable guarantees.
- Equivalence to a serial order. Whatever interleaving actually ran, the committed result is identical to running the transactions in some sequence with no overlap. You never have to reason about interleavings — only about the transaction in isolation.
- Every anomaly blocked. Dirty reads, non-repeatable reads, phantoms, lost updates, and write skew are all impossible. If a set of concurrent transactions could produce a non-serializable result, at least one is aborted.
- Not a guaranteed order. "Some serial order" does not mean the order you expect. Serializable prevents anomalies; it does not promise T1 runs before T2.
Postgres SSI — how serializable works without heavy locking.
-
Snapshot isolation base. Every serializable transaction starts as a snapshot-isolation transaction (same reads as
repeatable read), so readers still never block writers. - Dependency tracking. Postgres records the read/write dependencies between concurrent transactions using lightweight predicate locks (SIReadLocks) — these do not block anyone; they only record "this transaction read data that another might invalidate."
-
Dangerous-structure detection. When the committed dependency graph would contain a cycle that no serial order could produce, SSI aborts one transaction in the cycle with
40001. This is the "safe retry" mechanism. - False positives are possible. SSI is conservative: it may abort a transaction that would actually have been fine, trading a few unnecessary retries for never missing a real conflict. Higher contention means more aborts.
Two-phase locking — the other implementation.
- Strict 2PL. Lock-based engines acquire shared locks on reads and exclusive locks on writes, holding all locks until commit. Serializability falls out of the locking discipline.
- Range / predicate locks. To block phantoms, the engine locks not just rows but ranges (key ranges, gap locks, next-key locks), so a concurrent INSERT into a scanned range blocks.
-
The trade-off. 2PL blocks more (readers can block writers and vice versa) and can deadlock; SSI aborts more (retries) but keeps readers non-blocking. Postgres chose SSI; MySQL InnoDB's
serializableis closer to lock-based (it turns plainSELECTs into locking reads).
Common interview probes on serializable.
- "What does serializable guarantee?" — equivalence to some serial execution; all anomalies blocked.
- "How does Postgres implement it?" — SSI (snapshot isolation + dependency tracking), not locking.
- "What must the application do?" — catch
40001and retry. - "When would you NOT use serializable?" — hot single-row counters where atomic UPDATE or FOR UPDATE is cheaper and retries would thrash.
Worked example — write skew blocked by serializable (the doctors problem)
Detailed explanation. The canonical write-skew scenario: two on-call doctors, an invariant "at least one doctor must remain on call," and two transactions that each check the invariant and each go off-call. At repeatable read both succeed and nobody is on call; at serializable one is aborted. Walk through it.
-
Setup.
doctors(name PK, on_call BOOLEAN)with Alice and Bob bothon_call = true. -
Invariant.
count(*) WHERE on_callmust stay>= 1. - Two transactions. Alice's session and Bob's session each try to go off call concurrently.
Question. Show that repeatable read violates the invariant and serializable preserves it.
Input.
| Time | Alice's txn | Bob's txn |
|---|---|---|
| t0 | count on_call -> 2 | count on_call -> 2 |
| t1 | sees 2 >= 2, sets Alice off | sees 2 >= 2, sets Bob off |
| t2 | COMMIT | COMMIT |
Code.
-- REPEATABLE READ — write skew slips through
-- Alice's session:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call; -- -> 2
-- 2 >= 1 after removing me? (2-1=1) ok, safe to go off
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;
-- Bob's session (concurrent, same snapshot count of 2):
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call; -- -> 2 (didn't see Alice)
UPDATE doctors SET on_call = false WHERE name = 'Bob';
COMMIT;
-- Result: BOTH off call. Invariant "at least one on call" VIOLATED.
-- SERIALIZABLE — the dependency is detected
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM doctors WHERE on_call; -- read set tracked
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT; -- one commits; the other -> 40001 -> retry -> sees count 1 -> refuses
Step-by-step explanation.
- Both sessions open at
repeatable readand count2doctors on call. Each snapshot is independent; neither sees the other's uncommitted change. - Alice's transaction reasons "removing me leaves
2 - 1 = 1, still ≥ 1" and sets Alice off call. Bob's transaction reasons identically about Bob. Each update targets a different row. - Because the two UPDATEs touch different rows, there is no single-row write conflict —
repeatable readcommits both. The result is zero doctors on call: the invariant is violated even though each transaction, in isolation, preserved it. - This is write skew: overlapping reads (both read the on-call set), disjoint writes (Alice's row vs Bob's row), and a constraint over the set that neither transaction alone can see being broken.
- At
serializable, Postgres records that each transaction read the on-call set and then wrote into it. When the second commits, SSI sees a dependency cycle that no serial order allows (in any serial order, the second transaction would have counted1and refused) and aborts it with40001. The retry counts1and correctly declines.
Output.
| Level | Alice | Bob | On-call count |
|---|---|---|---|
| repeatable read | off | off | 0 (violated) |
| serializable | off | 40001 → retry → stays on | 1 (preserved) |
Rule of thumb. Any invariant defined over a set of rows (at least one on call, sum ≥ 0, at most N active) that transactions check-then-modify is a write-skew hazard. Row locks and repeatable read do not close it — only serializable does.
Worked example — SSI conflict detection and the abort decision
Detailed explanation. Understanding which transaction SSI aborts and why helps you design retry logic and predict throughput. SSI aborts based on read/write dependency edges forming a "dangerous structure," not on lock ordering. Walk through a two-transaction dependency graph.
- Setup. Two transactions T1 and T2 with a read/write dependency in each direction.
- The rule. SSI aborts a transaction when a "pivot" has both an inbound and outbound rw-dependency, forming a potential cycle.
Question. Trace the dependency edges for a write-skew pair and identify which transaction aborts.
Input.
| Edge | Meaning |
|---|---|
| T1 -rw-> T2 | T1 read data T2 then wrote |
| T2 -rw-> T1 | T2 read data T1 then wrote |
| pivot | transaction with in + out rw edges |
| abort | first committer wins; later one aborts |
Code.
-- T1 and T2 each read a set the other writes into
-- T1:
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT sum(amount) FROM postings WHERE batch = 10; -- reads batch-10 set
INSERT INTO postings(batch, amount) VALUES (10, 50); -- writes into batch-10 set
-- T2 (concurrent):
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT sum(amount) FROM postings WHERE batch = 10; -- reads batch-10 set
INSERT INTO postings(batch, amount) VALUES (10, 70); -- writes into batch-10 set
-- Whichever commits first wins:
-- T1: COMMIT; -> succeeds
-- T2: COMMIT; -> ERROR 40001 (could not serialize access
-- due to read/write dependencies among transactions)
Step-by-step explanation.
- T1 reads the batch-10 set (via
sum) and then inserts into it. T2 does the same. Each transaction has read data that the other transaction writes — a read/write (rw) dependency in both directions. - SSI models this as a graph:
T1 -rw-> T2(T1 read something T2 wrote) andT2 -rw-> T1. A transaction that has both an incoming and an outgoing rw-edge is a "pivot" — the structure that can create a non-serializable cycle. - Postgres does not abort eagerly at the read; it records the dependencies with non-blocking predicate locks and lets both transactions run. The decision is deferred to commit time.
- The first transaction to commit wins unconditionally. When the second transaction attempts to commit and Postgres sees the dangerous structure completed by that commit, it aborts the second with
40001, citing read/write dependencies. - This "first committer wins, later committer aborts" policy is why retry logic works cleanly: the aborted transaction re-runs after the winner's changes are committed and visible, so it makes its decision against the new state.
Output.
| Transaction | Commits first? | Outcome |
|---|---|---|
| T1 | yes | success |
| T2 | no | 40001 abort → retry |
| retried T2 | — | sees T1's insert; recomputes; commits |
Rule of thumb. SSI's policy is "first committer wins." Design retries to be idempotent-on-recompute — the retried transaction must re-read and re-decide against fresh state, never blindly re-apply the aborted transaction's intended write.
Worked example — the retry loop with backoff for serializable throughput
Detailed explanation. Under contention, serializable transactions abort and retry; without backoff, retries can thrash and collapse throughput. The production pattern is a bounded retry loop with exponential backoff and jitter, plus a metric on retry counts. Walk through it.
-
The loop. Catch
40001(and40P01deadlock), roll back, sleep with jittered backoff, retry up to N times. - The metric. Track retries per operation; a rising retry rate is the early-warning that a hot key needs a different design.
Question. Write the production-grade serializable retry wrapper and explain each guard.
Input.
| Parameter | Value |
|---|---|
| Retryable codes | 40001 (serialization), 40P01 (deadlock) |
| Max retries | 5 |
| Backoff | exponential + jitter |
| Metric | retries_total counter |
Code.
import random
import time
import psycopg2
from psycopg2 import errorcodes
RETRYABLE = {errorcodes.SERIALIZATION_FAILURE, errorcodes.DEADLOCK_DETECTED}
def run_serializable(conn, op, *, max_retries: int = 5, base: float = 0.01):
"""Run `op(cur)` in a SERIALIZABLE txn with bounded, jittered retries."""
for attempt in range(max_retries):
try:
with conn: # BEGIN / COMMIT
with conn.cursor() as cur:
cur.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
result = op(cur) # caller's read+write logic
metrics_retries.observe(attempt) # 0 on first-try success
return result
except psycopg2.Error as e:
conn.rollback()
if e.pgcode not in RETRYABLE or attempt == max_retries - 1:
raise # non-retryable or exhausted
# exponential backoff with full jitter
sleep = random.uniform(0, base * (2 ** attempt))
time.sleep(sleep)
raise RuntimeError("unreachable")
Step-by-step explanation.
- The
RETRYABLEset holds the two SQLSTATE codes worth retrying:40001(serialization failure) and40P01(deadlock). Any other error — constraint violation, syntax error — is a real bug and must not be retried. - Each attempt opens a fresh transaction, sets
serializable, and runs the caller'sop(cur)which does the read-then-write logic. On clean commit the wrapper recordsattempt(the number of prior retries) as a metric and returns. - On error the wrapper rolls back, then checks the SQLSTATE. If it is not retryable or the retry budget is exhausted, it re-raises — the caller sees the real failure rather than an infinite loop.
- For a retryable error within budget, the wrapper sleeps for a jittered exponential backoff (
random.uniform(0, base * 2**attempt)). Jitter is essential: without it, many contending transactions retry in lockstep and re-collide, amplifying the storm. - The retry metric is the operational signal. A retry rate that climbs with load means a hot key or a design that forces serialization; the fix is usually to move the invariant into a single atomic UPDATE or a dedicated counter table rather than raising retry limits.
Output.
| Scenario | Behaviour |
|---|---|
| First-try success | commit; retries metric = 0 |
| One 40001 | rollback, jittered sleep, retry → success |
| Persistent conflict | up to 5 retries, then raise |
| Non-retryable error | rollback, raise immediately |
Rule of thumb. Serializable without a retry loop is a latent outage. Ship the loop with bounded retries, exponential backoff with jitter, and a retry-rate metric — and treat a rising retry rate as a signal to redesign the hot path, not to raise the retry cap.
SQL Interview Question on serializable
A senior interviewer might ask: "You must enforce 'the total balance across a customer's checking and savings accounts never goes negative' while both accounts can be debited by concurrent transactions. SELECT ... FOR UPDATE on the debited row does not prevent the overdraft. Explain why, and design the correct solution on Postgres including the failure-handling contract."
Solution Using serializable with dependency tracking and a bounded retry loop
-- WHY FOR UPDATE fails: it locks only the row you debit, not the row you read
-- Session A debits checking; Session B debits savings; each reads BOTH,
-- each locks only its own target row -> no lock conflict -> both commit -> overdraft.
-- CORRECT: SERIALIZABLE tracks that each txn READ the sum across both rows
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- read the invariant's inputs (both accounts)
SELECT sum(balance) AS total
FROM accounts
WHERE customer_id = 42; -- e.g. total = 100 (checking 60 + savings 40)
-- app checks: total - debit >= 0 ? (100 - 80 = 20 ok)
UPDATE accounts
SET balance = balance - 80
WHERE customer_id = 42 AND account = 'checking';
COMMIT; -- if a concurrent debit on savings would jointly overdraw,
-- one txn aborts with 40001 and retries against fresh totals
def debit(conn, customer_id: int, account: str, amount: int) -> bool:
def op(cur):
cur.execute(
"SELECT coalesce(sum(balance),0) FROM accounts WHERE customer_id = %s",
(customer_id,))
total = cur.fetchone()[0]
if total - amount < 0:
return False # would overdraw; refuse
cur.execute(
"UPDATE accounts SET balance = balance - %s "
"WHERE customer_id = %s AND account = %s",
(amount, customer_id, account))
return True
return run_serializable(conn, op) # retry wrapper from earlier
Step-by-step trace.
Input — customer 42 has checking 60, savings 40 (total 100); Session A debits checking 80, Session B debits savings 80, concurrently.
- FOR UPDATE attempt. A reads both balances, locks only the checking row it will debit. B reads both balances, locks only the savings row it will debit. The two locks are on different rows, so neither blocks — both proceed.
-
FOR UPDATE overdraft. A sees total
100, debits checking to-20? No — A debits80from checking60, leaving-20unless guarded; even guarding each row individually, A sees total100and thinks100 - 80 ≥ 0. B independently sees total100, debits savings80. Joint total is100 - 160 = -60. Overdraft, no lock conflict. Write skew again. -
Serializable, reads tracked. A and B each
SELECT sum(balance)— SSI records that each read the set of both account rows for customer 42. - Serializable, writes tracked. A updates checking; B updates savings. Each has written into the set the other read: a bidirectional rw-dependency, the dangerous structure.
-
Serializable, commit. The first to commit (say A) succeeds; balance total becomes
20. B's commit completes a non-serializable cycle, so SSI aborts B with40001. B retries, re-reads the total (20), computes20 - 80 < 0, and correctly refuses. No overdraft.
Output:
| Approach | Session A | Session B | Total balance |
|---|---|---|---|
| SELECT FOR UPDATE | commits | commits | -60 (overdraft) |
| serializable | commits (total 20) | 40001 → retry → refuses | 20 (safe) |
Why this works — concept by concept:
-
Row lock insufficiency —
SELECT ... FOR UPDATElocks only the rows it names; a cross-row invariant read from rows you do not lock is invisible to the lock manager, so two disjoint writes race through. - Predicate-read tracking — Postgres SSI records the read set (the sum over both accounts) with non-blocking SIReadLocks, capturing exactly the dependency that row locks miss.
-
Dangerous structure abort — when both transactions have read-into-then-written the shared set, SSI detects the rw-cycle at commit and aborts the second committer with
40001. - Bounded retry with recompute — the retried transaction re-reads the now-committed total and re-decides, so the invariant holds without the application enumerating interleavings.
- Cost — SSI adds read-set tracking (bounded per-transaction memory) and retries proportional to true conflict rate; for a genuine multi-row invariant it is the only correct tool, and its cost is strictly less than the cost of a silent overdraft.
SQL
Topic — sql
SQL serializable and write-skew problems
5. MVCC — how engines implement isolation
mvcc makes every isolation level a snapshot rule over versioned rows — readers never block writers, and vacuum keeps the version chain from bloating
The mental model in one line: Multi-Version Concurrency Control (mvcc) implements isolation by keeping multiple versions of every row — in Postgres each version (tuple) carries an xmin (the transaction id that created it) and an xmax (the transaction id that expired it) — so a transaction's snapshot is just a rule that picks, for each logical row, the one version that was committed before the snapshot and not yet expired, which lets readers see a stable point-in-time view without taking any read locks (readers never block writers, writers never block readers) at the cost of accumulating dead versions that a background VACUUM must reclaim. Every isolation level you have read about is, underneath, a policy for when a transaction takes its snapshot and which dependencies it tracks — the version machinery is identical.
Row versions — xmin, xmax, and the tuple chain.
-
xmin. The transaction id (
xid) that inserted this tuple version. A tuple is only visible if itsxmincommitted and is visible to your snapshot. -
xmax. The transaction id that deleted or updated this tuple version (0 / null if still live). An UPDATE writes a new tuple and sets the old tuple's
xmaxto the updating xid — the old version lingers for snapshots that still need it. -
The chain. One logical row can have many physical versions at once: the current live one plus older dead ones still visible to long-running snapshots.
SELECT xmin, xmax, ctid FROM texposes them. -
No in-place update. Postgres never overwrites a row in place; every UPDATE is an insert-new-plus-expire-old. This is why UPDATE-heavy tables bloat and why HOT (heap-only tuple) updates and
fillfactormatter.
Snapshots — the visibility rule.
-
What a snapshot is. A record of "which transactions had committed at the moment I was taken": conceptually
xmin(oldest still-running xid),xmax(first not-yet-assigned xid), and the list of in-progress xids (xip_list). -
Visibility test. A tuple is visible to a snapshot if its inserting
xminis committed and ≤ snapshot (and not in the in-progress list) and itsxmaxis null, aborted, or > snapshot (i.e. not yet expired from the snapshot's point of view). -
Level = snapshot cadence.
read committedtakes a new snapshot per statement;repeatable read/serializabletake one per transaction. That single difference produces all the behavioural differences between the levels. - Readers don't block writers. Because a reader resolves each row to a committed version via the snapshot, it never needs a lock on the row a writer is changing — the writer just creates a new version the reader ignores.
The xmin horizon and long transactions.
- The horizon. The oldest snapshot xmin across all active transactions. Any dead tuple newer than the horizon might still be needed by some running snapshot, so it cannot be reclaimed.
- Long transactions pin the horizon. A transaction that stays open for an hour holds the horizon back an hour, so every dead tuple produced in that hour is un-reclaimable — the classic cause of runaway bloat.
-
Idle-in-transaction is the villain. A connection that runs
BEGIN, does one query, then sits idle holds a snapshot and pins the horizon.idle_in_transaction_session_timeoutexists to kill these.
VACUUM — reclaiming dead tuples.
- What it does. Scans tables for tuples that are dead to all snapshots (older than the xmin horizon) and marks their space reusable; updates the visibility map and the free space map.
- Autovacuum. A background process triggered by row-change thresholds. It also prevents transaction-id wraparound by freezing very old tuples.
-
What it does not do (usually). Plain
VACUUMdoes not return disk to the OS — it makes space reusable within the table.VACUUM FULLrewrites the table and returns space but takes an exclusive lock. - Bloat symptoms. Table/index size far exceeding live-row size, slow scans, autovacuum "not keeping up" warnings — almost always traced to long transactions or high UPDATE churn.
Common interview probes on MVCC.
- "How do readers avoid blocking writers?" — snapshots over row versions; readers resolve to a committed version.
- "What are xmin and xmax?" — creating xid and expiring xid of a tuple version.
- "Why do long transactions cause bloat?" — they pin the xmin horizon so vacuum can't reclaim dead tuples.
- "What does vacuum do?" — reclaims dead tuples for reuse and prevents xid wraparound.
Worked example — the xmin/xmax lifecycle of an updated row
Detailed explanation. The clearest way to internalise MVCC is to watch xmin/xmax change across an INSERT, UPDATE, and DELETE using Postgres's system columns. Walk through the lifecycle of a single logical row.
-
Setup.
items(id, name); observexmin,xmax,ctidafter each DML. - The point. One logical row becomes several physical tuples; the snapshot picks which one you see.
Question. Show how xmin/xmax evolve across INSERT → UPDATE → DELETE and how a concurrent old snapshot still sees the pre-update version.
Input.
| DML | Effect on tuples |
|---|---|
| INSERT | new tuple, xmin=T_ins, xmax=0 |
| UPDATE | old tuple xmax=T_upd; new tuple xmin=T_upd, xmax=0 |
| DELETE | live tuple xmax=T_del (still on disk until vacuum) |
| VACUUM | reclaims tuples dead to all snapshots |
Code.
-- Inspect MVCC system columns directly
CREATE TABLE items (id INT PRIMARY KEY, name TEXT);
INSERT INTO items VALUES (1, 'alpha');
SELECT xmin, xmax, ctid, * FROM items WHERE id = 1;
-- xmin=1001 xmax=0 ctid=(0,1) | 1 | alpha (one live version)
UPDATE items SET name = 'beta' WHERE id = 1;
SELECT xmin, xmax, ctid, * FROM items WHERE id = 1;
-- xmin=1002 xmax=0 ctid=(0,2) | 1 | beta (NEW tuple; old (0,1) now has xmax=1002)
-- A concurrent REPEATABLE READ txn that snapshotted before 1002
-- still resolves id=1 to the OLD tuple (0,1) 'alpha'.
DELETE FROM items WHERE id = 1;
-- the live tuple (0,2) gets xmax = <delete xid>; row still physically present.
VACUUM items; -- once no snapshot needs them, dead tuples (0,1)/(0,2) are reclaimed
Step-by-step explanation.
- The INSERT creates one physical tuple at
ctid (0,1)withxmin = 1001(the inserting transaction) andxmax = 0(not expired). Any snapshot after 1001 commits seesalpha. - The UPDATE does not modify the existing tuple. It writes a new tuple at
(0,2)withxmin = 1002(the updating transaction) and sets the old tuple(0,1)'sxmax = 1002, marking it expired as of transaction 1002. - Now two physical tuples exist for
id = 1. A transaction whose snapshot predates 1002 (e.g. arepeatable readtransaction that started earlier) still sees(0,1)alpha, because for its snapshot the old tuple'sxmaxis "in the future" and the new tuple'sxminis invisible. - The DELETE sets
xmaxon the live tuple(0,2)to the deleting xid. The row is logically gone but physically remains on the page until vacuum, so snapshots that still need it can read it. -
VACUUMruns later and reclaims tuples whosexmaxis committed and older than the xmin horizon (dead to every snapshot). Until then the dead tuples occupy space — the mechanism behind bloat.
Output.
| Step | Live tuple seen by new snapshot | Tuples on page |
|---|---|---|
| after INSERT | (0,1) alpha | 1 |
| after UPDATE | (0,2) beta | 2 (one dead) |
| after DELETE | none | 2 (both dead) |
| after VACUUM | none | 0 (reclaimed) |
Rule of thumb. Remember "UPDATE = insert new + expire old." Every UPDATE leaves a dead tuple behind, so UPDATE-heavy tables need aggressive autovacuum and a sensible fillfactor to enable HOT updates — otherwise the version chain and its indexes bloat.
Worked example — snapshot visibility across two isolation levels
Detailed explanation. The same version chain produces different reads depending on when the snapshot is taken. Show a concurrent UPDATE and how read committed versus repeatable read resolve the row through the visibility rule. Walk through it.
-
Setup.
items(1, 'alpha'); a writer updates it to'beta'and commits. -
Two readers. One at
read committed, one atrepeatable read, each reading before and after the writer's commit.
Question. Show which tuple version each reader resolves and tie it to the xmin/xmax visibility test.
Input.
| Reader | Snapshot cadence | Sees before commit | Sees after commit |
|---|---|---|---|
| read committed | per statement | alpha | beta |
| repeatable read | per transaction | alpha | alpha |
Code.
-- Writer:
BEGIN;
UPDATE items SET name = 'beta' WHERE id = 1; -- new tuple xmin=W; old xmax=W
-- (not committed yet)
-- Reader R1 at READ COMMITTED:
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT name FROM items WHERE id = 1; -- -> alpha (W uncommitted, hidden)
-- ... writer COMMITs ...
SELECT name FROM items WHERE id = 1; -- -> beta (fresh snapshot sees W)
COMMIT;
-- Reader R2 at REPEATABLE READ (snapshot taken before writer commits):
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT name FROM items WHERE id = 1; -- -> alpha (snapshot S pinned)
-- ... writer COMMITs ...
SELECT name FROM items WHERE id = 1; -- -> alpha (S still hides W's new tuple)
COMMIT;
Step-by-step explanation.
- The writer's UPDATE creates a new tuple with
xmin = Wand expires the old one withxmax = W, butWis uncommitted, so to every other snapshot the new tuple is invisible and the old tuple is not yet expired. - R1 (
read committed) reads before the writer commits: its snapshot sees the old tuple (xmax = Wis uncommitted → treat as not expired) and returnsalpha. The new tuple'sxmin = Wis uncommitted → invisible. - After the writer commits, R1's next statement takes a fresh snapshot in which
Wis committed and ≤ snapshot. Now the visibility test flips: the old tuple'sxmax = Wis committed-and-past → expired → hidden; the new tuple'sxmin = Wis committed-and-past → visible. R1 returnsbeta. - R2 (
repeatable read) took snapshot S before the writer committed. S recordsWas in-progress. Even afterWcommits, S's frozen membership still treatsWas invisible, so R2 keeps resolving the row to the old tuple and returnsalphaon both reads. - The two readers differ only in snapshot cadence — the version chain and the visibility test are identical. This is the concrete mechanism behind "read committed sees committed changes; repeatable read freezes the view."
Output.
| Reader | Read #1 | Writer commits | Read #2 |
|---|---|---|---|
| read committed | alpha | — | beta |
| repeatable read | alpha | — | alpha |
Rule of thumb. The isolation level is not a different storage engine — it is a different snapshot cadence over the same xmin/xmax version chain. Reason about any anomaly by asking "what does this snapshot's visibility test resolve each tuple to?"
Worked example — a long transaction that bloats the table via a pinned horizon
Detailed explanation. The most common MVCC production incident: a long-running or idle-in-transaction session pins the xmin horizon, so vacuum cannot reclaim dead tuples and the table (and its indexes) bloat. Walk through the diagnosis and fix.
- Symptom. Table size grows while live-row count is flat; queries slow down; autovacuum logs show "removable cutoff" not advancing.
-
Root cause. One old snapshot (a long transaction or
idle in transactionsession) holds the horizon back.
Question. Diagnose the pinned horizon and write the queries and settings that resolve it.
Input.
| Signal | Where to look |
|---|---|
| Oldest snapshot age | pg_stat_activity (xact_start, state) |
| Bloat | live tuples vs relation size |
| Vacuum blocked | autovacuum "cutoff" not advancing |
| Guard | idle_in_transaction_session_timeout |
Code.
-- 1. Find the oldest transaction pinning the horizon
SELECT pid,
state,
now() - xact_start AS txn_age,
now() - state_change AS since_state_change,
left(query, 60) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
OR state = 'idle in transaction'
ORDER BY xact_start ASC NULLS LAST
LIMIT 5;
-- 2. See how far back the horizon is held (oldest xmin)
SELECT backend_xmin,
age(backend_xmin) AS xmin_age
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC
LIMIT 5;
-- 3. Terminate a stuck idle-in-transaction session (surgical fix)
SELECT pg_terminate_backend(<pid>);
-- 4. Prevent recurrence: cap idle-in-transaction sessions
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
SELECT pg_reload_conf();
-- 5. After the horizon advances, vacuum can finally reclaim
VACUUM (VERBOSE) big_table;
Step-by-step explanation.
- Query 1 lists sessions by transaction start time. A session with a large
txn_ageorstate = 'idle in transaction'for minutes is the prime suspect — it holds a snapshot and pins the horizon. - Query 2 shows
backend_xminand its age; the session with the oldestbackend_xminis literally the one preventing vacuum from advancing its removable cutoff. Every dead tuple newer than that xmin is un-reclaimable. - The surgical fix is
pg_terminate_backend(pid)on the offending session. Once it ends, its snapshot is released and the horizon jumps forward to the next-oldest snapshot. - The durable fix is
idle_in_transaction_session_timeout— Postgres automatically aborts any transaction left idle beyond the limit, so a leakedBEGINfrom application code can no longer pin the horizon indefinitely. - With the horizon advanced,
VACUUM(or autovacuum) can mark the accumulated dead tuples reusable. If the table has already bloated badly,VACUUM FULL(exclusive lock) orpg_repack(online) reclaims the disk, but the goal is to prevent the pin in the first place.
Output.
| State | Horizon | Vacuum can reclaim? |
|---|---|---|
| long txn open | pinned at old xmin | no (bloat grows) |
| session terminated | advances | yes |
| timeout configured | can't be pinned by idle | prevented |
Rule of thumb. Bloat is almost always a held-back xmin horizon, not a vacuum that is too slow. Hunt the oldest snapshot in pg_stat_activity first, set idle_in_transaction_session_timeout, and keep transactions short — especially at repeatable read/serializable, which hold their snapshot for the whole transaction.
SQL Interview Question on MVCC
A senior interviewer might ask: "An UPDATE-heavy Postgres table has grown to 40 GB while holding only ~2 GB of live rows, and query latency has doubled. Autovacuum is running but 'not keeping up.' Walk me through how MVCC produced this bloat, how you would confirm the root cause, and the fix — both the immediate one and the design change that prevents recurrence."
Solution Using horizon diagnosis, vacuum tuning, and fillfactor for HOT updates
-- 1. Confirm bloat: live rows vs relation size
SELECT relname,
n_live_tup,
n_dead_tup,
pg_size_pretty(pg_relation_size(relid)) AS heap_size,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'events'
ORDER BY n_dead_tup DESC;
-- 2. Confirm a pinned horizon (the usual root cause)
SELECT pid, state, now() - xact_start AS age, backend_xmin
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC
LIMIT 3;
-- 3. Immediate reclaim (online, no exclusive lock like VACUUM FULL)
-- pg_repack rewrites the table+indexes without a long lock.
-- $ pg_repack -t events -d production
-- 4. Design fix A: enable HOT updates by leaving free space per page
ALTER TABLE events SET (fillfactor = 85); -- new UPDATEs can stay on-page
-- 5. Design fix B: make autovacuum far more aggressive for this hot table
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.02, -- vacuum at 2% churn, not default 20%
autovacuum_vacuum_cost_limit = 2000, -- let autovacuum do more work per round
autovacuum_vacuum_insert_scale_factor = 0.05
);
-- 6. Guard: stop long/idle transactions from pinning the horizon
ALTER SYSTEM SET idle_in_transaction_session_timeout = '2min';
SELECT pg_reload_conf();
Step-by-step trace.
Input — events table: n_live_tup ≈ 2 GB worth of rows, pg_total_relation_size ≈ 40 GB, high UPDATE rate, one long-lived reporting transaction.
-
Confirm bloat. Query 1 shows
total_size(40 GB) dwarfing live rows and a largen_dead_tup. This is dead-tuple accumulation, the MVCC signature: every UPDATE left an old version behind. -
Find the cause. Query 2 shows a reporting session with an ancient
backend_xmin. Because that snapshot might still need the dead versions, autovacuum's removable cutoff is stuck behind it — vacuum runs but reclaims almost nothing. -
Immediate reclaim. After ending the long transaction (or once it finishes), the horizon advances.
pg_repackrewrites the table and indexes online to return the 38 GB of dead space to the OS without the exclusive lockVACUUM FULLwould take. -
Prevent churn bloat.
fillfactor = 85leaves 15% free space per page so an UPDATE can often place the new tuple on the same page as the old (a HOT update), which avoids adding index entries and lets vacuum clean the old version cheaply. -
Prevent recurrence. Aggressive per-table autovacuum thresholds keep dead tuples reclaimed continuously, and
idle_in_transaction_session_timeoutensures no leaked or long-idle transaction can pin the horizon again. The design change, not the one-time repack, is what keeps the table healthy.
Output:
| Metric | Before | After |
|---|---|---|
| Total relation size | 40 GB | ~3 GB |
| Dead tuples | very high | near zero (steady) |
| Query latency | 2× baseline | baseline |
| Horizon | pinned by long txn | advances freely |
| HOT-update ratio | low | high (fillfactor 85) |
Why this works — concept by concept:
- Dead-tuple accumulation — every UPDATE/DELETE leaves an expired tuple version that occupies space until vacuum reclaims it; UPDATE-heavy tables generate dead tuples fast.
- Pinned xmin horizon — a long or idle-in-transaction session holds the oldest snapshot xmin, so vacuum cannot reclaim any tuple newer than it; the real root cause of most bloat.
- HOT updates via fillfactor — leaving free space per page lets an UPDATE keep the new version on-page without new index entries, so the version chain and indexes bloat far less.
- Aggressive autovacuum — lowering the scale factor makes autovacuum reclaim at 2% churn instead of 20%, keeping dead tuples from ever piling up on a hot table.
-
Cost —
pg_repackis a one-time online rewrite (temporary 2× space); the ongoing cost is slightly more autovacuum CPU and 15% looser page packing, cheap insurance against O(bloat) latency growth.
Database
Topic — database
Database MVCC and vacuum problems
Optimization
Topic — optimization
Optimization problems on bloat and vacuum tuning
Cheat sheet — isolation level recipes
-
ANSI anomaly matrix.
read uncommitted= dirty + non-repeatable + phantom all possible;read committed= dirty blocked, non-repeatable + phantom possible;repeatable read= dirty + non-repeatable blocked, phantom possible (ANSI);serializable= all blocked. The matrix is a floor: engines may be stricter (Postgres RR also blocks phantoms). Lost update and write skew live outside the ANSI matrix — read committed and (for write skew) even repeatable read permit them. -
Per-engine defaults. PostgreSQL defaults to
read committedand offers RC / RR (= snapshot isolation) / SERIALIZABLE (= SSI); it acceptsread uncommittedbut promotes it to RC. MySQL InnoDB defaults torepeatable read(with next-key locks blocking phantoms on locking reads) and offers all four. Oracle defaults toread committed, supports SERIALIZABLE (snapshot-based), and does not offer RR or RU. SQL Server defaults to lock-basedread committed, offers all four plus optimisticSNAPSHOT(READ_COMMITTED_SNAPSHOT). Never assume "the default" without naming the engine. -
Setting the level. Per transaction:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;orSET TRANSACTION ISOLATION LEVEL SERIALIZABLE;as the first statement afterBEGIN. Session default:SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL .... Cluster default:default_transaction_isolationinpostgresql.conf. AddREAD ONLYfor report transactions to document intent and skip write bookkeeping. -
Pick the level by the invariant. Single-row atomic change →
read committed+ guarded atomic UPDATE (SET x = x - n WHERE x >= n). Read-modify-write needing multiple reads →read committed+SELECT ... FOR UPDATE. Coherent multi-statement read →repeatable read(READ ONLY). Cross-row / aggregate invariant (write skew) →serializable+ retry. Escalate only as far as the invariant demands. -
SELECT ... FOR UPDATE / FOR SHARE.
FOR UPDATEtakes an exclusive row lock so concurrent writers queue behind you — the fix for lost update when the write depends on multiple column reads.FOR SHAREtakes a shared lock (others can read, not write).FOR UPDATE SKIP LOCKEDpowers work-queue patterns (each worker grabs a different unlocked row);FOR UPDATE NOWAITfails fast instead of blocking. Row locks do not prevent write skew across different rows. -
Serialization-failure retry pattern. Any writer at
repeatable readorserializablemust catch SQLSTATE40001(serialization_failure) and40P01(deadlock_detected), roll back, and retry the whole transaction against a fresh snapshot — with bounded attempts, exponential backoff and jitter, and a retry-rate metric. A rising retry rate signals a hot key to redesign, not a retry cap to raise. -
Postgres RR vs ANSI RR. Postgres
repeatable readis snapshot isolation and blocks phantoms; the ANSI standard permits them. Do not port "RR allows phantoms" reasoning from a textbook onto Postgres — but do remember RR (Postgres or ANSI) still permits write skew, which onlyserializablecloses. -
The write-skew tell. Two transactions read an overlapping set, check a constraint, then write disjoint rows that jointly break it (doctors both off-call, both sub-accounts overdrawn, count-then-insert past a limit). No row updated twice ⇒
FOR UPDATEand RR do not help ⇒ escalate toserializable. -
MVCC in one breath. Every row has versions tagged
xmin(creator xid) /xmax(expirer xid); a snapshot picks the version committed-before-and-not-yet-expired; readers never block writers because they read a version, not a lock. Isolation level = when the snapshot is taken (per-statement vs per-transaction) plus, for serializable, what dependencies are tracked. -
Vacuum + bloat note. UPDATE = insert-new + expire-old, so UPDATE-heavy tables accumulate dead tuples.
VACUUMreclaims tuples dead to all snapshots; it can only reclaim past the xmin horizon, which a long/idle-in-transaction session pins. Diagnose withpg_stat_activity.backend_xmin; prevent with short transactions,idle_in_transaction_session_timeout, aggressive autovacuum, andfillfactorfor HOT updates. -
When to escalate — and when not to. Escalate to
serializablefor genuine cross-row invariants where you would otherwise have to enumerate interleavings. Do not reach forserializableon hot single-row counters — an atomic UPDATE is cheaper and never retries. Over-escalation trades correctness bugs for throughput collapse under contention. -
Migration / audit checklist. When changing a transaction's level: (1) re-audit every write for lost-update exposure, (2) add/verify the
40001retry loop, (3) check for held-open snapshots that will pin the horizon, (4) load-test the retry rate under peak contention, (5) confirm the engine's actual behaviour (Postgres RR ≠ ANSI RR) rather than trusting the standard.
Frequently asked questions
What are SQL isolation levels in one sentence?
SQL isolation levels are the contract that specifies which concurrency anomalies a transaction is allowed to observe when it runs alongside other transactions — the ANSI standard defines four levels as an increasing ladder (read uncommitted, read committed, repeatable read, serializable) where each rung forbids one more anomaly (dirty reads, then non-repeatable reads, then phantom reads) at the cost of more locking, snapshot bookkeeping, or transaction aborts. The level is a correctness decision, not a performance knob: it determines whether your transaction can read uncommitted data, whether a re-read of the same row can change, and whether a range query can grow a phantom row. Because the standard defines levels by permitted anomalies rather than implementation, the same level behaves differently across engines — which is why senior interviews probe both the taxonomy and the engine's actual mechanism.
Read committed vs repeatable read — when do I pick each?
Pick read committed (the Postgres default) for the vast majority of OLTP work: it blocks dirty reads, takes a fresh snapshot per statement, and never holds a snapshot across the whole transaction, so it puts the least pressure on vacuum. Its weakness is that two statements in one transaction can see different committed states, so a re-read of the same row can change (non-repeatable read) and read-modify-write can lose updates. Pick repeatable read when a single transaction reads the same data more than once and must see a stable, coherent view — reconciliations, multi-statement consistency checks, and reports across related tables. On Postgres, repeatable read is snapshot isolation and additionally blocks phantoms, but it still permits write skew, and any writer at RR must handle 40001 serialization failures with a retry loop. Escalate only as far as the invariant demands; over-escalating trades throughput for guarantees you may not need.
What is a phantom read and which level blocks it?
A phantom read happens when a transaction re-runs the same range query — a WHERE predicate over a set of rows — and gets a different set of rows because another transaction committed an INSERT or DELETE that changed which rows match. Unlike a non-repeatable read (where an existing row's value changes), a phantom changes the membership of the result set while the rows you already saw stay put. By the ANSI standard, only serializable is guaranteed to block phantoms; repeatable read may permit them. In practice it depends on the engine: PostgreSQL's repeatable read is snapshot isolation and blocks phantoms, and MySQL InnoDB's repeatable read blocks them for locking reads via next-key locks. Even where phantoms are blocked, a count-then-insert limit check across sessions can still fall to write skew, which needs serializable.
Does Postgres repeatable read block phantoms?
Yes — this is a common point of confusion. The ANSI standard permits phantoms at repeatable read, but PostgreSQL implements repeatable read as full snapshot isolation, which freezes one snapshot for the whole transaction and hides every row committed after that snapshot, including newly inserted rows that match a range predicate. So a range query re-run inside a Postgres RR transaction returns the identical set — no phantom. Postgres RR is therefore strictly stronger than ANSI RR. The important caveat is that snapshot isolation still permits write skew: two transactions can read an overlapping set, check a constraint, and write disjoint rows that jointly break it. If your correctness depends on a constraint spanning rows you read but did not lock, you must use serializable (Postgres SSI), not RR.
What is MVCC and how does it relate to isolation levels?
MVCC (Multi-Version Concurrency Control) is the mechanism that keeps multiple versions of each row so that readers see a consistent point-in-time snapshot without taking read locks — readers never block writers and writers never block readers. In PostgreSQL, each row version (tuple) carries an xmin (the transaction id that created it) and an xmax (the transaction id that expired it); a transaction's snapshot is a rule that selects, for each logical row, the version committed before the snapshot and not yet expired. Every isolation level is implemented on top of this same machinery — the only differences are when the snapshot is taken (read committed re-snapshots per statement; repeatable read and serializable snapshot once per transaction) and, for serializable, which read/write dependencies are tracked. The cost of MVCC is dead tuples: an UPDATE writes a new version and expires the old one, so VACUUM must reclaim dead versions, and a long-running transaction that pins the xmin horizon is the usual cause of table bloat.
What is serializable isolation and when do I actually need it?
serializable is the strongest isolation level: it guarantees the concurrent execution of a set of transactions produces the same result as some one-at-a-time serial ordering, so every anomaly — dirty reads, non-repeatable reads, phantoms, lost updates, and write skew — is impossible. PostgreSQL implements it with Serializable Snapshot Isolation (SSI): snapshot isolation plus non-blocking tracking of read/write dependencies, aborting one transaction with 40001 whenever the committed dependency graph would form a cycle no serial order allows. You need it when correctness depends on an invariant spanning multiple rows or an aggregate you read but did not lock — the classic write-skew cases (at least one doctor on call, total balance ≥ 0 across accounts, at most N active rows per user). You do not need it for hot single-row updates, where a guarded atomic UPDATE or SELECT ... FOR UPDATE is cheaper and never retries. Whenever you use it, wrap writers in a bounded, jittered 40001 retry loop.
Practice on PipeCode
- Drill the SQL practice library → for the transaction, isolation-level, lost-update, and write-skew problems senior interviewers love.
- Rehearse on the database practice library → for MVCC, snapshot visibility, locking, and vacuum-tuning scenarios.
- Sharpen the concurrency intuition on the data-processing practice library → for consistency checks and multi-statement snapshot coherence.
- Harden your retry and invariant logic on the defensive-coding practice library → and tune the cost side on the optimization practice library →.
- Stack these against PipeCode's broader 450+ data-engineering catalogue to anchor the four-level ladder against real graded inputs.
Lock in isolation-level muscle memory
Docs define the levels. PipeCode drills teach the decision — when read committed lets an update slip through, when repeatable read still permits write skew, when serializable's retry loop earns its place, and when MVCC bloat becomes a 3 AM incident. Pipecode.ai is Leetcode for Data Engineering — concurrency-first practice tuned for the production trade-offs senior engineers actually face.





Top comments (0)