Skip to main content
databases

MVCC

10 min read
Fully authored

How Postgres keeps readers and writers out of each other's way.

In 1978, David Reed at MIT published his PhD thesis: "Naming and Synchronization in a Decentralized Computer System." Buried in it was an idea that would revolutionize database design: Multi-Version Concurrency Control — MVCC. The insight: readers should never block writers, and writers should never block readers. Achieve this by keeping multiple versions of every row. Every read sees a consistent snapshot from a specific point in time. Every write creates a new version without touching the version older readers are looking at.

Traditional locking databases blocked liberally. If you were reading a row, no one could write to it. If you were writing, no one could read. On big tables with hot rows, this crushed concurrency — 5 concurrent users could grind a database to a halt because they all wanted the same page. MVCC dissolved this. Postgres (1986), Oracle (1988 via undo tablespaces), and later MySQL InnoDB (2001 via undo logs) all adopted variants of MVCC. Every serious modern database now has MVCC — CockroachDB, Spanner, YugabyteDB, TiDB, MongoDB, SQL Server (since 2005), MariaDB.

The trade-off is bloat. Every UPDATE creates a new tuple version; the old one becomes dead and must eventually be garbage collected. Postgres's VACUUM, Oracle's undo tablespace cleanup, InnoDB's purge thread — all fight the same battle: reclaim space from dead versions without blocking live readers. Get this wrong and your database bloats. Get it right and you have unlimited concurrent readers with zero contention.

The papers

  • Reed (1978) — "Naming and Synchronization in a Decentralized Computer System." MIT PhD thesis. First formal MVCC treatment.
  • Bernstein & Goodman (1983) — "Multiversion Concurrency Control — Theory and Algorithms." ACM TODS. Formal foundation.
  • Stonebraker et al. (1986) — Postgres design papers introducing time-travel via MVCC.
  • Ports & Grittner (2012) — "Serializable Snapshot Isolation in PostgreSQL." How SSI extends MVCC.

The core idea — every row has a version chain

In an MVCC database, a row is not one thing — it's a chain of versions, each tagged with the transaction that created it and the transaction that deleted it. When you read, the database walks the chain and finds the version that was "alive" at your transaction's start time.

Row versioning — one logical row, multiple physical tuples
v1 (Alice)
balance: $500
xmin: T100 (INSERT) · xmax: T200
dead
v2 (Alice)
balance: $600
xmin: T200 (UPDATE from v1) · xmax: T350
dead
v3 (Alice)
balance: $400
xmin: T350 (UPDATE from v2) · xmax: T500
dead
v4 (Alice)
balance: $450
xmin: T500 (UPDATE from v3) · xmax: 0 (current)
live
4 updates → 4 tuples on disk. Only v4 is currently "live." A transaction that started at time T=175 would see v1 (only v1 was committed then). A transaction at T=400 would see v3. VACUUM will eventually reclaim v1, v2, v3 once no active transaction can see them.

In Postgres each row has two hidden columns: xmin (the transaction ID that inserted this version) and xmax (the transaction ID that deleted or updated it — 0 if still current). Every transaction has a transaction ID (XID) incremented monotonically. Every read query captures a snapshot — the set of XIDs it should consider "visible." The visibility rule is:

  • Version is visible iff xmin is committed and in the snapshot, AND xmax is either 0 or not yet committed / not in the snapshot.

Interactive: readers and writers don't block

Reader ↔ writer non-blocking — step 1 of 5
R and W execute concurrently. Neither blocks the other.
T=100
reader
BEGIN
snapshot=T100
Reader begins transaction R at T=100. Captures a snapshot: 'I see all committed data as of T=100.'

Watch the reader and writer proceed in parallel — they never wait on each other. The reader sees the snapshot at transaction start. The writer creates a new version. Both commit. This is impossible in a strict locking database.

Snapshot isolation — the isolation level MVCC unlocks

MVCC naturally implements snapshot isolation — every transaction sees a consistent view of the database as of its start time. This is the isolation level Postgres, Oracle, and MySQL InnoDB call "REPEATABLE READ" (though the SQL-92 standard defines it slightly differently). Snapshot isolation prevents:

  • Dirty reads — you never see uncommitted data. Every version has an xmin that must be committed to be visible.
  • Non-repeatable reads — read the same row twice, get the same answer. Because your snapshot doesn't change during your transaction.
  • Phantom reads — a query with a range condition returns the same rows the second time. New inserts happen at higher XIDs not in your snapshot.

But snapshot isolation is not fully serializable. It admits one specific anomaly called write skew — two transactions read overlapping data and each updates a different piece based on what the other read. Both commit successfully but their combined effect violates an invariant that neither individually would violate.

Write skew — the one anomaly snapshot isolation allows
Scenario: A hospital has an on-call policy — at least 1 doctor must be on call at all times. Two doctors (Alice, Bob) are both on call. Both simultaneously request to go off-call.
Transaction A (Alice)
1. SELECT COUNT(*) FROM doctors WHERE on_call=TRUE
→ returns 2 (Alice, Bob)
2. Sees ≥1, safe to go off-call
3. UPDATE doctors SET on_call=FALSE WHERE name='Alice'
4. COMMIT
Transaction B (Bob) — parallel
1. SELECT COUNT(*) FROM doctors WHERE on_call=TRUE
→ returns 2 (Alice, Bob)
2. Sees ≥1, safe to go off-call
3. UPDATE doctors SET on_call=FALSE WHERE name='Bob'
4. COMMIT
Result: Both transactions read the same snapshot showing 2 doctors on call. Both saw "≥1" and went off-call. Both committed successfully. Now 0 doctors on call. Invariant violated! Snapshot isolation didn't stop this.
SSI fix: Postgres SERIALIZABLE tracks the reads (a "predicate lock"). When both writes conflict on the invariant, one is aborted with a serialization failure — the app retries.

Postgres 9.1 (2011) shipped Serializable Snapshot Isolation (SSI) — detects potential write-skew via predicate locks and aborts one of the conflicting transactions. Full serializability with MVCC's reader-writer concurrency. This is what most modern databases mean by "SERIALIZABLE."

The bloat problem — VACUUM and its friends

Every UPDATE creates a new tuple. The old one is dead — no live transaction can see it — but it still occupies disk space until reclaimed. If you don't clean up, your table bloats. Fast writes make it worse: at 10k updates/sec, an unchecked table can double in size in minutes.

VACUUM — reclaiming dead tuples
Autovacuum
Background daemon runs continuously. Triggers per-table when dead tuple ratio exceeds threshold.
VACUUM (manual)
Runs on demand. Marks dead tuples as reusable space. Does NOT return space to OS.
VACUUM FULL
Rewrites the entire table without dead tuples. Returns space to OS but requires ACCESS EXCLUSIVE lock.
VACUUM FREEZE
Marks old xmin values as 'frozen' to prevent XID wraparound (a 4-billion-transaction limit).

Postgres has autovacuum — a background daemon that periodically scans tables looking for dead tuples. It reclaims space, updates visibility maps, and prevents XID wraparound. Oracle uses undo tablespaces that automatically age out old versions after a retention period. InnoDB has a purge thread. Every MVCC engine has some form of this — it's the price of the reader-writer non-blocking property.

MVCC vs locking — the fundamental choice

DimensionMVCCStrict locking (2PL)
Reader blockingNever blocks writersReader takes shared lock — writers wait
Writer blockingNever blocks readersWriter takes exclusive lock — readers wait
Read isolationConsistent snapshot at transaction start (no phantoms)READ COMMITTED default (may see phantoms unless higher isolation)
Storage costMultiple versions on disk; VACUUM neededSingle version — no bloat
ConcurrencyScales linearly with coresContended rows become bottlenecks
Write skewPossible under snapshot isolation (fix: SSI)Impossible under 2PL
Real usersPostgres, Oracle, MySQL InnoDB, MongoDB, Cockroach, Spanner, etc.Older SQL Server (default before 2005), some embedded engines

Applied in real systems

PostgreSQL
Deep dive

PostgreSQL — the reference MVCC implementation

Postgres uses per-row xmin/xmax. All versions stored in the heap. VACUUM (auto + manual) reclaims space. Serializable Snapshot Isolation shipped in 9.1. The MVCC engine most other DBs are compared against.

Read the deep dive →
Oracle Database
Deep dive

Oracle — undo tablespace + consistent read

Oracle stores old versions in a separate undo tablespace. Reads reconstruct the old row on-the-fly by applying undo. Configurable retention (UNDO_RETENTION). This is a different implementation choice from Postgres, same MVCC semantics.

Read the deep dive →
MySQL InnoDB
Deep dive

MySQL InnoDB — undo logs + purge thread

InnoDB stores per-row DB_TRX_ID + DB_ROLL_PTR pointing at undo log entries. Old versions live in the undo log. Purge thread cleans up. REPEATABLE READ is the default isolation (same snapshot behavior as PG).

Read the deep dive →
CockroachDB
Deep dive

CockroachDB — MVCC across distributed KV

Every key has multiple versions in RocksDB (later Pebble). Timestamp = HLC. Reads pick the latest version < snapshot timestamp. GC is TTL-based. Distributed transactions use MVCC + optimistic concurrency + intents → very Postgres-like from user perspective.

Read the deep dive →
Google Spanner
Deep dive

Spanner — MVCC + TrueTime for global reads

Every write gets a TrueTime timestamp bounded to the atomic-clock uncertainty. Reads at time t return the version committed at <=t. External consistency is preserved by waiting out uncertainty. Same MVCC principles, extraordinary engineering underneath.

Read the deep dive →
MongoDB
Deep dive

MongoDB WiredTiger — MVCC via snapshots

WiredTiger uses page-level snapshots. Every read gets a snapshot at transaction start. Old versions kept in memory + on-disk history store. Snapshot isolation the default; transactions since v4.0 support majority write concern.

Read the deep dive →
SQL Server
Deep dive

SQL Server — READ_COMMITTED_SNAPSHOT since 2005

SQL Server 2005 added optional row versioning. Enable with ALLOW_SNAPSHOT_ISOLATION or READ_COMMITTED_SNAPSHOT. Old versions stored in tempdb. Not default (many old SQL Server apps still assume locking behavior).

Read the deep dive →
YugabyteDB / TiDB
Deep dive

YugabyteDB & TiDB — Spanner-style MVCC + Raft

Both use Raft-replicated KV underneath (RocksDB). Every key has multiple versions, keyed by HLC. Percolator-style (TiDB) or Spanner-style (Yugabyte) transactions on top. Full ACID + horizontal scale + MVCC read consistency.

Read the deep dive →

Key takeaways

  • MVCC keeps multiple versions of every row. Readers never block writers, writers never block readers.
  • Every version has metadata: xmin (creator), xmax (deleter). Reads use a snapshot to decide which versions are visible.
  • MVCC naturally implements snapshot isolation. Prevents dirty reads, non-repeatable reads, phantoms — but not write skew.
  • Serializable Snapshot Isolation (SSI) — MVCC + predicate locks. Postgres 9.1+, others. Full serializability without giving up MVCC.
  • Bloat is the cost. Dead tuples accumulate; VACUUM (PG), undo tablespace (Oracle), purge thread (InnoDB) all fight this.
  • Every serious modern database uses MVCC: Postgres, Oracle, MySQL InnoDB, SQL Server, CockroachDB, Spanner, TiDB, YugabyteDB, MongoDB. Locking-only databases are rare historical artifacts.
  • MVCC + HLC/TrueTime timestamps enables distributed MVCC — the foundation of NewSQL systems that offer ACID at planetary scale.

References

  • Reed (1978) — Naming and Synchronization in a Decentralized Computer System.
  • Bernstein & Goodman (1983) — Multiversion Concurrency Control.
  • Ports & Grittner (2012) — Serializable Snapshot Isolation in PostgreSQL. VLDB.
  • Kleppmann (2017) Designing Data-Intensive Applications, Chapter 7.
  • Bailis et al. (2013) — "Highly Available Transactions" — how MVCC relates to CAP.
  • Postgres official docs on MVCC + VACUUM + XID wraparound.

Practice what you just read

Every foundation concept has a companion quiz to close the loop.