MVCC
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.
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
xminis committed and in the snapshot, ANDxmaxis either 0 or not yet committed / not in the snapshot.
Interactive: readers and writers don't block
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
xminthat 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.
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.
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
| Dimension | MVCC | Strict locking (2PL) |
|---|---|---|
| Reader blocking | Never blocks writers | Reader takes shared lock — writers wait |
| Writer blocking | Never blocks readers | Writer takes exclusive lock — readers wait |
| Read isolation | Consistent snapshot at transaction start (no phantoms) | READ COMMITTED default (may see phantoms unless higher isolation) |
| Storage cost | Multiple versions on disk; VACUUM needed | Single version — no bloat |
| Concurrency | Scales linearly with cores | Contended rows become bottlenecks |
| Write skew | Possible under snapshot isolation (fix: SSI) | Impossible under 2PL |
| Real users | Postgres, Oracle, MySQL InnoDB, MongoDB, Cockroach, Spanner, etc. | Older SQL Server (default before 2005), some embedded engines |
Applied in real systems
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.
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.
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).
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.
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.
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.
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).
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.
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.