MVCC and Concurrency Control

The reason a long analytics query doesn't block every writer in your database — and vice versa — is a single elegant idea: never overwrite data, keep multiple versions, and give each transaction a consistent snapshot in time. MVCC is how nearly every modern database delivers isolation without readers and writers fighting over locks.

The previous post established that isolation is a spectrum and that databases must let many transactions run concurrently without corrupting each other. This post covers how they do it. The naive approach — lock everything — works but destroys concurrency. The approach that actually powers PostgreSQL, MySQL/InnoDB, Oracle, and most modern databases is Multi-Version Concurrency Control (MVCC), and its central trick is keeping old versions of rows so that readers never block writers and writers never block readers.

The problem with locks

The simplest way to enforce isolation is locking: before reading or writing a row, acquire a lock so no one else can touch it. Two-phase locking (2PL) using shared (read) and exclusive (write) locks can even provide serializability. But pure locking has a crippling cost: readers and writers block each other. A transaction writing a row holds an exclusive lock, so anyone wanting to read that row must wait — and a long-running read (a report, an analytics scan) holding read locks can block all writers to those rows.

In a system with mixed read and write traffic, this is disastrous: one slow query stalls everything it touches. The insight behind MVCC is that most of this contention is unnecessary, because a reader doesn’t actually need the latest value — it needs a consistent value.

The MVCC idea: keep versions

MVCC keeps multiple versions of each row. When a transaction updates a row, the database does not overwrite the old value in place — it creates a new version and leaves the old one intact. Each version is stamped with the transaction that created it. A reader is then served the version that was current as of when its transaction (or statement) started — a consistent snapshot of the database frozen at a point in time.

The consequence is the property that makes MVCC transformative:

Row "balance":
   v1 (=100, by txn 10, committed)   ← reader in snapshot ≤ txn 15 sees this
   v2 (=90,  by txn 20, committed)   ← reader in snapshot ≥ txn 20 sees this
A reader started at txn 15 reads v1 (100) even while txn 20 wrote v2 (90).
No locks, no blocking.

This is why you can run a big read-only report against a live transactional database and neither freeze the writers nor see a smeared, half-updated picture — the report reads a stable snapshot while writers march on creating new versions.

Snapshots and visibility

The heart of MVCC is the visibility rule: given a row’s versions, which one does this transaction see? Each version records the transaction ID that created it (and, on update/delete, the transaction that superseded it). A transaction takes a snapshot — essentially “which transactions had committed at my start” — and a version is visible to it if it was created by a transaction that had committed as of the snapshot and not yet superseded by another committed version in that snapshot.

This is exactly how isolation levels from the previous post are implemented:

So MVCC isn’t a separate feature from isolation levels — it’s the machinery that provides them, with the level determining when snapshots are taken. Snapshot isolation (Repeatable Read in Postgres) is a direct, natural product of MVCC.

The cost: old versions must be cleaned up

MVCC’s elegance has a price that every operator of these databases eventually meets: old versions accumulate. Every update leaves a dead prior version; every delete leaves a version that’s invisible to new transactions but not yet physically removed. If nothing cleaned them up, the database would grow without bound and slow down as it wades through dead versions. So MVCC databases run a garbage collection process to reclaim space from versions no longer visible to any live transaction:

Two operational lessons follow directly:

The full picture: MVCC plus some locking

MVCC handles read/write concurrency, but databases still use locks for the parts MVCC alone can’t cover:

So the modern concurrency-control picture is: MVCC for read/write concurrency (snapshots, no read locks), plus targeted locking or abort-and-retry for write/write conflicts and serializability. That combination is what lets a database be simultaneously highly concurrent and correct — the goal the whole correctness half of this series has been building toward.

Key takeaways

Further reading

Sources & References