CMD Guide
HomeDatabasesTransactions & Concurrency Control

MVCC & Locking — Snapshots, Row Locks & Deadlocks

How databases give isolation without everyone blocking

If every read took a lock, one long report would freeze every writer. Modern databases (Postgres, InnoDB, Oracle) avoid this with MVCC — Multi-Version Concurrency Control. A write never overwrites a row in place; it produces a new version and leaves the old one intact. Each transaction reads against a snapshot — the set of row versions that were committed and visible when the snapshot was taken. The payoff is the single sentence to remember: readers never block writers, and writers never block readers. They only ever touch the version their own snapshot points at.

The mechanism (PostgreSQL model — the clearest one)

Every row version (a tuple) carries two hidden transaction-id fields:

The visibility rule. A reader with a given snapshot sees a version if and only if: (1) its xmin is committed and visible to the snapshot — that transaction had committed before the snapshot was taken and is not in the snapshot's in-progress list; and (2) its xmax is not visible to the snapshot — either xmax=0, or the deleting transaction is still uncommitted / in the in-progress list. In one line: visible = creator is committed-and-in-the-past AND deleter is not.

A snapshot is not a single number. It is really three things: the lowest still-active txid, the next txid to be handed out, and — crucially — the list of txids in progress when the snapshot was taken. A version is visible only if its xmin committed and is not in that in-progress list. That in-progress list, not id order alone, is why a transaction that started before you but committed after your snapshot stays invisible to you.

What an UPDATE actually does. It does not mutate the row. It performs two moves atomically: it stamps the old version's xmax with the updating transaction's id (marking it superseded), and it inserts a brand-new version whose xmin is that same updating transaction's id. Until the writer commits, every reader keeps seeing the old version — no read lock is ever taken. That is precisely why readers don't block writers and writers don't block readers.

Airtight trace: one row, a writer, and a concurrent reader

Row id=7, starting balance=100. Version v1 was created by an earlier, already committed transaction id=5 — so v1 begins life as xmin=5, xmax=0 (live). Now Txn A (id=10) updates the balance from 100 to 150, while Txn B (id=11) reads concurrently. Assume B runs at REPEATABLE READ, so B takes its snapshot once at BEGIN and keeps it for the whole transaction (this is what makes the repeated reads below stable — see the isolation caveat after the table). Watch every xmin/xmax value at each step.

TimeTxn A (id=10) — writerTxn B (id=11) — readerRow versions after this step (xmin / xmax)
t0v1: xmin=5, xmax=0 (created by committed txn 5; live, balance=100)
t1BEGIN; reads v1 → balance=100v1: xmin=5, xmax=0
t2BEGIN; snapshot taken (txn 10 still in progress → in B's in-progress list)v1: xmin=5, xmax=0
t3UPDATE balance 100→150v1: xmin=5, xmax=10 (uncommitted); v2: xmin=10, xmax=0 (uncommitted, balance=150)
t4reads row → still sees v1 = 100v1: xmin=5, xmax=10; v2: xmin=10, xmax=0 (A's xmax/xmin are uncommitted → invisible to B)
t5COMMITv1: xmin=5, xmax=10 (now committed-dead); v2: xmin=10, xmax=0 (now committed, live)
t6reads row again → still sees v1 = 100txn 10 is in B's in-progress list → v2 stays invisible to B all the way to B's commit
t7B COMMIT; a new snapshot now starts → sees v2 = 150v2 visible (xmin=10 committed and not in-progress); v1 hidden (xmax=10 committed)
t8no live snapshot can still need v1 → VACUUM reclaims v1's space

Isolation caveat (important): the stable re-reads at t4/t6 assume B is at REPEATABLE READ or SERIALIZABLE, where the snapshot is fixed for the whole transaction. Under PostgreSQL’s default READ COMMITTED, each statement takes a fresh snapshot — so B’s second read at t6 (after A commits at t5) would see v2 = 150, not v1 = 100. The MVCC bookkeeping (xmin/xmax) is identical either way; only which snapshot B compares against changes.

Read the bookkeeping off the table: every xmin/xmax equals the id of the transaction that created or superseded that version. Txn 5 created v1. Txn A (id=10) superseded v1 (so v1.xmax=10) and created v2 (so v2.xmin=10). Txn B (id=11) never appears in any tuple field — because B only reads, it creates and supersedes nothing. Final resting state: v1: xmin=5, xmax=10 and v2: xmin=10, xmax=0.

Why this matters: at t4 and t6, B reads the pre-update balance even though A has already written — and even after A commits — because A was in progress when B's snapshot was taken. No read lock was ever needed. The old version is kept only because some snapshot may still need it, and is reclaimed the moment none can.

Version chain for row id=7: v1 (balance=100, xmin=5, xmax=10) superseded by v2 (balance=150, xmin=10, xmax=null); Txn B's earlier snapshot sees v1=100 while a later snapshot sees v2=150
Version chain for row id=7: v1 (balance=100, xmin=5, xmax=10) superseded by v2 (balance=150, xmin=10, xmax=null); Txn B's earlier snapshot sees v1=100 while a later snapshot sees v2=150

Locking still exists — for writes

MVCC solves read/write concurrency. It does not solve write/write concurrency. Two transactions updating the same row cannot both fork a new version off it — that would be a lost update. So the engine takes a row-level exclusive lock: the first updater holds it until commit, and the second updater waits (blocks) until the first commits or rolls back, then re-reads and proceeds. This is the one place a writer blocks — on another writer of the same row.

MVCC alone does not prevent every anomaly

A snapshot read is stale by construction, which opens two classic hazards under the common Read Committed / Repeatable Read levels:

To close these you need either explicit locking (FOR UPDATE) or the SERIALIZABLE isolation level. In PostgreSQL that is SSI (Serializable Snapshot Isolation): it runs on MVCC snapshots (reads still never block) but tracks read/write dependencies and aborts a transaction whose commit would create a non-serializable cycle — so you must wrap transactions in retry-on-serialization-failure logic.

Deadlock: when two writers wait on each other

Row locks introduce the possibility of a cycle: S1 holds row a and wants row b; S2 holds row b and wants row a. Here the DB does what your application can't — it detects the wait-for cycle and aborts one transaction as the victim (you get a deadlock error; retry it). Same cycle as the Dining Philosophers; same fix — a consistent lock ordering.

-- deadlock: two sessions, opposite update order
-- S1: UPDATE a; UPDATE b;     S2: UPDATE b; UPDATE a;
-- Postgres: "ERROR: deadlock detected" -> one is rolled back.
-- Fix: always update rows in a consistent order, e.g. ascending id.

Selection & trade-offs: MVCC vs strict two-phase locking

The alternative to MVCC is strict two-phase locking (S2PL) — the model behind SQL Server's REPEATABLE READ/SERIALIZABLE (its default is lock-based READ COMMITTED with short-duration read locks, not S2PL) and MySQL's SERIALIZABLE — where reads take shared locks and writes take exclusive locks, all held to commit.

How to decide: choose MVCC (the default in Postgres/InnoDB/Oracle) when reads dominate and you can afford vacuum/undo maintenance — almost all OLTP and reporting mixes. Reach for explicit FOR UPDATE locks or SERIALIZABLE only on the specific transactions whose correctness needs it. Prefer pessimistic S2PL-style locking when write contention is high and constant and you'd rather block than pile up retries. Optimistic (MVCC + retry) wins when conflicts are rare; pessimistic (lock-first) wins when they're common.

Pitfalls a working engineer hits

Recall question

Txn A updates a row and commits. Txn B (running at REPEATABLE READ) started before A committed and is still open. Why does B still see the old version? Answer: B's snapshot captured the list of transactions in progress at B's start, and A was in it. Visibility is decided by snapshot + commit order (specifically that in-progress list), not by wall-clock time. Because REPEATABLE READ freezes that snapshot for the whole transaction, A's new version stays invisible to B until B ends. (At READ COMMITTED, B's next statement would take a fresh snapshot and see the new version.) The old version is reclaimed by VACUUM only once no live snapshot can see it.

Takeaways

Scaling the hot counter: the single row that serializes everyone

Above we saw that writers to the same row serialize on that row's exclusive lock — the second updater blocks until the first commits. That is usually fine. It becomes a wall when the row is hot: a global view/like/inventory counter taking 10,000 increments/second. Each UPDATE counters SET n = n + 1 WHERE id = 'views' grabs the row's X-lock, so every one of those 10k writers per second is forced through a single lock, one at a time. Throughput collapses to 1 / (lock-hold time) no matter how many cores or connections you throw at it; the rest pile up waiting. MVCC does not save you here — MVCC only stops readers and writers blocking each other, and this is pure write/write contention on one row.

The insight: the lock is on the row, not on the logical counter. So stop making one row carry all the writes. Three fixes, in increasing order of how much accuracy you trade away.

Fix 1 — sharded (striped) counter: split the row into K sub-rows

Represent one logical counter as K physical rows. A writer increments one of the K chosen by a hash of something it already has (its worker/thread id, connection pid, or a random pick), so writes spread across K independent row-locks. The true value is the SUM across shards, computed only at read time.

CREATE TABLE counter_shard (
  counter_id text   NOT NULL,
  shard      int    NOT NULL,
  value      bigint NOT NULL DEFAULT 0,
  PRIMARY KEY (counter_id, shard)
);
-- seed K=16 shards once
INSERT INTO counter_shard (counter_id, shard, value)
SELECT 'views', g, 0 FROM generate_series(0, 15) AS g;

-- WRITER: hit exactly one of the 16 shard rows.
-- Pick the shard from something the writer already holds (thread/worker id),
-- so contention on any single row drops by ~16x.
UPDATE counter_shard
   SET value = value + 1
 WHERE counter_id = 'views'
   AND shard = (hashtext(pg_backend_pid()::text) % 16 + 16) % 16;  -- non-negative 0..15

-- READER: the logical total is the sum over shards.
SELECT sum(value) AS views FROM counter_shard WHERE counter_id = 'views';

Write contention on any one row falls by roughly K× (16 lock queues instead of 1). The cost moves to the read: a total now scans/sums K rows instead of reading 1. That is the whole trade — you buy write scalability with a K×-more-expensive read. Pick K near your write concurrency (tens, not thousands): too small and you still queue, too large and reads and storage bloat for no benefit.

Append-only variant. Instead of updating K fixed rows, some systems INSERT a delta row per event (never touching an existing row, so no row-lock contention at all) and periodically roll them up:

-- each increment is a brand-new row: zero contention on existing rows
INSERT INTO counter_delta (counter_id, delta) VALUES ('views', 1);
-- read = sum of un-rolled deltas + the rolled-up base
SELECT (SELECT base FROM counter_rollup WHERE counter_id = 'views')
     + COALESCE((SELECT sum(delta) FROM counter_delta WHERE counter_id = 'views'), 0);

This trades the row-lock away entirely for table growth and a background rollup job — the read is expensive until you compact, so it suits write-dominant, read-rare counters.

Fix 2 — write-batching / aggregation: fewer, fatter writes

Do not send one UPDATE +1 per event. Have each app instance accumulate increments in memory for a short window (e.g. 100 ms) and flush a single UPDATE ... SET value = value + :batch. 10k events/s becomes a handful of writes per second per instance, each holding the lock briefly. You give up real-time exactness (a crash loses the un-flushed batch) and add a small delay, in exchange for a massive drop in lock traffic. This composes with sharding.

Fix 3 — approximate counters: drop exactness on purpose

When the answer only needs to be roughly right — "2.4M views," unique-visitor estimates, dashboard cardinality — use a probabilistic structure instead of an exact sum. HyperLogLog estimates the count of distinct items in a few KB with a small, bounded error, and its registers merge associatively, so you can maintain per-shard sketches and union them on read with no coordination. For a plain event count, sampled or probabilistic ("Morris") counters increment only occasionally and scale the reported value. You trade a few percent of accuracy for near-unlimited write throughput and tiny storage.

Selection & trade-off

ApproachWrite contentionRead costAccuracyUse when
Exact single rowAll writers serialize on 1 X-lock (the wall)1 rowExactLow write rate; correctness on every write matters
K-sharded rowsReduced ~K× (K lock queues)Sum of K rowsExactHigh write rate, exact total still required (inventory, ledgers)
Write-batchingCut by batch factor; brief holds1 (or K) rowsExact once flushed; loses in-flight batch on crashBursty writes tolerant of a small flush delay
Approximate (HLL / sampled)Effectively none; merges without coordinationRead a sketchBounded error (few %)Metrics/analytics where "about right" is enough

Rule of thumb: reach for sharding first when you must stay exact and reads can afford the SUM; add batching to cut lock traffic further; drop to approximate only when the business genuinely does not need the exact number.

Work-claiming queues: SELECT ... FOR UPDATE SKIP LOCKED

Now the mirror-image problem. You want a job queue inside the database (a jobs table) with a pool of workers pulling tasks. The naive claim is a transaction that locks the next ready row so no one else takes it:

-- NAIVE: every worker races for the SAME oldest ready row
BEGIN;
SELECT id, payload FROM jobs
 WHERE state = 'ready'
 ORDER BY created_at
 FOR UPDATE          -- lock the row I intend to claim
 LIMIT 1;
-- ... work ...
COMMIT;

This is correct but it does not scale the workers. Every idle worker evaluates the same ORDER BY created_at LIMIT 1 and all of them try to lock the same oldest row. One wins the X-lock; the rest block on it (exactly the hot-row serialization from above). Adding workers adds waiters, not throughput.

SKIP LOCKED changes the semantics of the lock attempt: instead of blocking on a row another transaction already holds, the scan skips past it and returns the next row nobody has locked. So each worker atomically claims a different unlocked job, and N workers do N jobs in parallel with no contention.

-- WORKER: atomically claim one job nobody else holds, and mark it, in one statement.
UPDATE jobs
   SET state = 'processing', locked_at = now()
 WHERE id = (
        SELECT id FROM jobs
         WHERE state = 'ready'
         ORDER BY created_at
         FOR UPDATE SKIP LOCKED    -- skip rows other workers already locked
         LIMIT 1
      )
RETURNING id, payload;            -- returns 0 rows if the queue is empty

The inner SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1 finds and locks the first row no one else holds; the outer UPDATE ... RETURNING stamps it and hands the worker its payload — all atomic, so two workers can never get the same job. When the worker finishes it sets state = 'done' (or deletes the row) and commits, releasing the lock. A crash before commit rolls the lock back and the job becomes claimable again.

NOWAIT is the third option. Plain FOR UPDATE waits for the lock; SKIP LOCKED skips the locked row; FOR UPDATE NOWAIT errors immediately (55P03 lock_not_available) if the row is held. Use NOWAIT when you want a specific row and would rather fail fast and handle it than block — e.g. "grab job 42 or tell me it's busy right now."

Selection & trade-off: DB-as-queue vs the alternatives

The unifying idea across both sections: a row lock is your friend for correctness and your enemy for throughput. When many actors want the same row (a hot counter), spread the writes so they stop sharing one lock. When many actors want any row (a work queue), use SKIP LOCKED so each grabs a different lock instead of queueing on the same one.

Reclaiming the space: VACUUM vs VACUUM FULL vs pg_repack

MVCC never overwrites a row — an UPDATE or DELETE leaves the old version as a dead tuple. Understanding what actually frees that space is the follow-up that catches people: plain VACUUM marks dead tuples reusable but does NOT return space to the OS — the file stays the same size; the freed slots are added to the table's free-space map for future inserts. So after deleting 200M rows and running VACUUM, SELECT pg_total_relation_size(...) is unchanged and the disk is still full. That is working as designed, not a bug.

To actually shrink the file you need a rewrite:

Two follow-ups worth pre-empting: (1) a single giant DELETE in one transaction holds the xmin horizon back, so autovacuum can't even mark those tuples dead until it commits — batch the delete (keyset chunks) so the horizon advances and lag stays bounded; (2) transaction-ID wraparound is the emergency case — if autovacuum can't keep up, Postgres will eventually force a protective shutdown, so freeze-age monitoring is not optional at scale.

Selection: routine bloat → let autovacuum do its job (tune autovacuum_vacuum_scale_factor); need the disk back with a window → VACUUM FULL; need it back with no downtime → pg_repack; recurring bloat on a time-series table → partition and DROP/DETACH instead (metadata-only, no reclamation needed).

🎯 Drill Ladder — survive the follow-ups

L0 · MVCC gives isolation by versioning rows and reading per-transaction snapshots, so readers and writers don’t block — but writes still serialize via row locks, and dead versions must be reclaimed by VACUUM.

L1 · ① Concurrency — “You said readers never block writers. I run a huge multi-hour analytics report against the primary while OLTP writes stream in. The report finishes fine — but a week later the table is 3× its size and every query crawls. What happened?”
Trap: “A long read takes no locks, so it’s harmless.” — It takes no locks, but it holds an old snapshot, and that snapshot pins every row version created since it began as “maybe still visible.” The bloat is the tell.
Bar: MVCC’s cost isn’t locking, it’s retention. The oldest live snapshot sets the xmin horizon; VACUUM can only reclaim versions older than that horizon, so one long-running (or idle-in-transaction) reader blocks cleanup table-wide → table and index bloat. Watch beyond pg_stat_activity: replication slots (pg_replication_slots.xmin) and orphaned prepared transactions (pg_prepared_xacts) pin the horizon too. Fix: short transactions, analytics on a read replica, alarm on all three horizon-pinners. MVCC internals & recovery

L2 · ② Failure — “Two workers each update rows a then b in opposite order; one dies with ‘deadlock detected’. On-call says ‘just add a retry.’ Is that enough?”
Trap: “The DB already resolved it — retry the victim and move on.” — A blind retry re-runs the same opposite-order acquisition, so under load the cycle re-forms; you get a retry loop that makes progress only by luck, with latency spikes.
Bar: Retry is the safety net, not the fix. A deadlock is a wait-for cycle; make cycles impossible by imposing a global lock order — always touch rows in ascending primary-key order — so no two transactions can hold-and-wait in opposite directions. Retry-with-backoff mops up the residual. Same structure, same fix as the Dining Philosophers. deadlock / livelock / starvation

L3 · ⑤ Adversary/Edge — “Under MVCC my transaction reads a stable snapshot. So if I read balance=100, subtract 10 in app code, and write 90, the snapshot protects me from a concurrent withdrawal — right?”
Trap: “My snapshot is stable, so nobody can change the row under me.” — The snapshot abstraction leaks here: it freezes what your reads see, it does not grant mutual exclusion. Two transactions each read 100 and each write 90 → one decrement is lost.
Bar: Snapshot stability ≠ atomic read-modify-write. Do the arithmetic in the databaseUPDATE … SET balance = balance - 10, which blocks on the row lock and then re-reads and re-applies against the just-committed version (EvalPlanQual), so it composes correctly even at Read Committed — or take an explicit SELECT … FOR UPDATE, or a version-column CAS. App-side read-then-write is a lost update waiting to happen. lost update & isolation levels

L4 · ④ Time/Lifecycle — “Visibility is decided by transaction IDs. What happens after ~2 billion transactions?”
Trap: “IDs just keep incrementing — not my problem.” — Postgres transaction IDs are 32-bit and wrap around. Untended, rows far in the past can suddenly look as if they’re in the future and become invisible — silent, catastrophic data disappearance.
Bar: This is transaction-ID wraparound, and VACUUM prevents it by freezing — stamping old-enough rows as permanently visible before the id horizon can lap them. If autovacuum falls behind, Postgres escalates to a mandatory anti-wraparound vacuum and, at the hard limit, refuses to assign new transaction IDs (writes stop; read-only queries can still run) to protect the data. The lesson: VACUUM isn’t only about space, it’s about correctness and liveness. snapshot isolation internals

L5 · ⑥ Cost/Simplicity — “MVCC clearly has real costs — extra versions, vacuum, wraparound. Why not just go back to read locks / strict two-phase locking?”
Trap: “Locking is simpler and avoids all this version garbage.” — Pure lock-based (S2PL) isolation makes readers block writers and vice-versa, so a single long report freezes the whole OLTP workload. Escaping exactly that is why MVCC exists.
Bar: MVCC trades storage + background cleanup for read/write concurrency — the right trade for read-heavy OLTP, which is most systems. S2PL trades concurrency for no version bloat and no retry logic, winning only where write contention is high and constant. Real engines are hybrids: MVCC for reads, row locks for writes — exactly this page. Deciding variables: read/write ratio and transaction length. isolation levels & locking trade-offs

The floor keeps dropping: now run MVCC across replicas — a read on a follower sees a snapshot behind the primary (replication lag), so “read your own write” breaks. You’ve left single-node MVCC and entered distributed consistency.

Self-locate: missed the snapshot-retention → bloat link at L1 → mid-level; nailed wraparound (L4) and the S2PL trade-off (L5) → staff signal.

Facing any new concept? Hit it with the six: concurrent? failing? at 100×? over time? adversarial? worth the cost? — that’s the interviewer’s whole playbook.


Synthesized from the PostgreSQL MVCC documentation (heap tuples, xmin/xmax, VACUUM), CMU 15-445 (Andy Pavlo) on MVCC and 2PL, and Designing Data-Intensive Applications (Kleppmann, Ch. 7 — snapshot isolation, lost update, write skew, SSI). Hot-counter sharding and SKIP LOCKED work-queue patterns from the PostgreSQL docs (row-level locking, FOR UPDATE ... SKIP LOCKED / NOWAIT) and standard high-write-throughput practice. Version-chain diagram hand-authored as SVG. See also: Isolation Levels & Anomalies, Transactions & ACID, and (Concurrency) Deadlock. Re-authored/Deepened for this guide.

Production judgment

MVCC gives concurrent readers snapshots; it does not free you from writing correct updates. Lost updates still happen if two transactions read a balance/stock and write based on stale values without locks or conditional writes.

Private labs: Flash-sale WHERE stock>0 · Concurrent ledger posts · Rate limiter as the in-memory cousin of atomic RMW.

Staff drill: When do you want row locks vs optimistic version columns vs serializable?

🤖 Don't fully get this? Learn it with Claude

Stuck on MVCC & Locking — Snapshots, Row Locks & Deadlocks? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **MVCC & Locking — Snapshots, Row Locks & Deadlocks** (Databases) and want to truly understand it. Explain MVCC & Locking — Snapshots, Row Locks & Deadlocks from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **MVCC & Locking — Snapshots, Row Locks & Deadlocks** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **MVCC & Locking — Snapshots, Row Locks & Deadlocks** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **MVCC & Locking — Snapshots, Row Locks & Deadlocks** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes