Multiversion Concurrency Control

Imagine a long-running analytics query scanning a bank's account table. Meanwhile thousands of ATM withdrawals commit every second. Under strict two-phase locking, the query would need shared locks on every row it touches. This forces it to wait behind writers — or forces writers to wait behind it. Neither option is acceptable at scale. Multiversion Concurrency Control (MVCC) resolves this tension by never overwriting data in place. Instead, each write creates a new version. Each read is directed to the version that was current when the reader's transaction began. The consequence is profound: readers never block writers, and writers never block readers. This single property is why MVCC underlies nearly every serious production database. Examples include PostgreSQL, Oracle, MySQL/InnoDB, SQL Server (with snapshot isolation enabled). It also includes virtually all modern distributed SQL systems (Spanner, CockroachDB, YugabyteDB). Understanding MVCC means understanding something remarkable. It means seeing how a database can give every transaction the illusion of a private, frozen copy of the entire database. At the same time, it runs thousands of concurrent transactions against a single physical store. This article builds on the Timestamp-Ordering Protocol. MVCC generalizes this protocol by attaching version histories to timestamps. This replaces enforcing a single serial order on a single copy of data. It also contrasts sharply with Two-Phase Locking. That protocol achieves serializability through blocking rather than version proliferation. We derive the core multiversion timestamp-ordering algorithm from first principles. We prove why it guarantees serializability. Then we show how relaxing its full generality yields a hugely popular engineering compromise. This compromise is known as snapshot isolation. It is a topic that opens directly into the broader landscape of Isolation Levels. By the end, you will be able to trace, step by step, how a database decides which version of a row a transaction sees. You will understand why write-write conflicts still require detection, even though reads are conflict-free. You will also see why snapshot isolation, despite its popularity, permits a subtle anomaly. This is an anomaly that full serializability forbids.

Key takeaways
  • MVCC keeps multiple timestamped versions of each data item so read operations never need to wait for or block writes.
  • Each transaction reads the version of an item whose write-timestamp is the largest one not exceeding the transaction's own timestamp.
  • Multiversion timestamp-ordering (MV-TO) guarantees serializability equivalent to the timestamp order of transactions.
  • Old versions must eventually be garbage-collected once no active transaction can still need them.
  • Snapshot isolation is a practical relaxation of MVCC that avoids most anomalies but permits write skew.
  • MVCC trades storage and garbage-collection overhead for near-total elimination of read/write blocking.
The core insight

Instead of one mutable copy of the data guarded by locks, MVCC keeps a timestamped history of copies so that reads and writes never contend for the same physical object.

The idea

In a single-version database, a data item `X` has exactly one value at any instant. A transaction that wants to read `X` while another transaction is mid-write to `X` faces a genuine conflict: it must either wait, abort, or read an uncommitted (dirty) value. Multiversion Concurrency Control eliminates this conflict by storing a sequence of versions for each item, each version tagged with the timestamp of the transaction that created it. When transaction `T` reads `X`, the system does not read "the" value of `X`; it selects, from the version history, the specific version that was valid at `T`'s logical starting time. Formally, for data item `X` we maintain a list of versions ``` X1, X2, ..., Xk ``` where each version `Xi` carries three fields: `content(Xi)` (the value), `W-timestamp(Xi)` (the timestamp of the transaction that wrote it), and `R-timestamp(Xi)` (the largest timestamp among transactions that have read it). When a transaction with timestamp `TS(T)` issues `read(X)`, the system finds the version `Xi` with the largest `W-timestamp(Xi) ≤ TS(T)` and returns its content — updating `R-timestamp(Xi)` to `max(R-timestamp(Xi), TS(T))`. When `T` issues `write(X)`, the system must check that no transaction has already read a version that `T`'s write would have "should have" produced; if some `Xi` has `W-timestamp(Xi) ≤ TS(T) ≤ R-timestamp(Xi)`, then `T` is too late and must be rolled back. Otherwise, `T` creates a brand-new version `Xk+1` rather than overwriting anything. This is the crucial conceptual break from the single-version Timestamp-Ordering Protocol: because writes never destroy data that a reader might need, reads can never be rejected. A read only ever requires finding the right historical version, never waiting for one, and never conflicting with a concurrent writer. Writers still conflict with other writers (two transactions cannot both legitimately create "the" successor version for the same prior version), and a late writer can still conflict with an already-completed read — but read-write conflict, the dominant source of blocking in single-version locking protocols, is structurally impossible.

How it works

The multiversion timestamp-ordering algorithm (MV-TO) assigns each transaction `T` a unique timestamp `TS(T)` at start time, typically drawn from a monotonically increasing counter or synchronized clock. The version list for each item begins with a single "creation" version, often a dummy version written at time `−∞` (or time `0`) representing the initial database state.

Read rule. To execute `read(X)` for transaction `T`: ``` find the version Xi of X such that W-timestamp(Xi) is the largest write-timestamp <= TS(T) return content(Xi) set R-timestamp(Xi) = max(R-timestamp(Xi), TS(T)) ``` This always succeeds — there is always some version with `W-timestamp ≤ TS(T)`, because the initial dummy version has the smallest possible timestamp.

Write rule. To execute `write(X)` for transaction `T`: ``` find the version Xi of X such that W-timestamp(Xi) is the largest write-timestamp <= TS(T) if R-timestamp(Xi) > TS(T): // some transaction with a larger timestamp already read // a version older than the one T is trying to install — // T's write arrives "too late" to be consistent with that read abort T else if W-timestamp(Xi) == TS(T): // T already wrote this version earlier in its own execution; // overwrite it in place (no new version needed) content(Xi) = new value else: // safe to create a new version create new version Xk+1 with content(Xk+1) = new value W-timestamp(Xk+1) = TS(T) R-timestamp(Xk+1) = TS(T) ``` Why is the abort condition exactly `R-timestamp(Xi) > TS(T)`? Because `Xi` is the version that should have been visible to `T` had `T` run at its assigned logical time. If some other transaction with a larger timestamp already read `Xi` — meaning it read the value that existed before `T`'s write — then inserting `T`'s version between `Xi` and that reader would retroactively invalidate a read that has already happened. Since we cannot travel back in time to fix that read, `T` must abort. This is the multiversion analogue of the "reject late writes" rule in single-version timestamp ordering, generalized to a version chain instead of a single cell. Full pseudocode for the read/write dispatch, including version-chain maintenance and garbage collection, is given below. ``` function READ(T, X): Xi = version of X with max W-timestamp(Xi) <= TS(T) R-timestamp(Xi) = max(R-timestamp(Xi), TS(T)) return content(Xi) function WRITE(T, X, new_value): Xi = version of X with max W-timestamp(Xi) <= TS(T) if R-timestamp(Xi) > TS(T): abort(T) // late write, must retry with new TS return if W-timestamp(Xi) == TS(T): content(Xi) = new_value // overwrite T's own version else: create version Xk+1 of X content(Xk+1) = new_value W-timestamp(Xk+1) = TS(T) R-timestamp(Xk+1) = TS(T) function GARBAGE_COLLECT(X): let m = min(TS(T)) over all currently active transactions T // a version Xi can be reclaimed once no active or future // transaction could possibly need it: that is, once a // strictly newer version Xj (W-timestamp(Xj) < m) exists // and every version older than Xj is no longer the "current" // version for any transaction with timestamp < m remove any version Xi of X such that there exists a newer version Xj with W-timestamp(Xi) < W-timestamp(Xj) <= m ``` The garbage collector is essential in practice: without it, version chains grow without bound. It is safe to delete version `Xi` once a strictly newer version exists whose write-timestamp is still ≤ the oldest active transaction's timestamp `m`, because no active or future transaction (which will receive a timestamp ≥ current time > any currently active transaction's start) can ever need to read as of a time before `m` yet after `Xi`'s successor was written.

Figure
Version chains under MVCC: each reader sees the newest version no later than its own timestamp
Version chain for item X (timeline left → right) time X1 (init) W-ts=0 R-ts=12 X2 (T5) W-ts=5 R-ts=18 X3 (T20) W-ts=20 R-ts=20 T3: TS=3 reads X1 T12: TS=12 reads X2 T20: TS=20 reads X3 (its own write) T18: TS=18 reads X2 (newest ≤18) Rule: a transaction with timestamp t reads the version with the largest W-timestamp ≤ t. No reader is ever blocked by a writer.

Complexity & correctness

Time complexity. Each `read(X)` requires locating the correct version in `X`'s version chain. If versions are stored in a sorted structure (e.g., a balanced tree or a timestamp-ordered linked list with an index), this lookup costs `O(log v)` where `v` is the number of live versions of `X`; a naive unsorted linked list costs `O(v)`. Each `write(X)` costs the same lookup plus `O(1)` for version creation. Since reads never block, there is no wait-time cost analogous to lock queuing — the latency distribution of reads is dramatically more predictable than under two-phase locking, even though the asymptotic per-operation cost is comparable to an index lookup in a single-version system.

Space complexity. The dominant cost versus a single-version system is storage: `O(V)` extra space where `V` is the total number of live versions across all items, bounded by the write rate times the age of the oldest active transaction (the "vacuum horizon"). A long-running transaction that never commits can pin an unbounded number of old versions, causing bloat — this is the central operational tradeoff of MVCC.

Correctness (serializability). MV-TO produces a schedule equivalent to running transactions in an order consistent with their assigned timestamps — the same guarantee as single-version Timestamp-Ordering Protocol, lifted to a versioned store. The proof sketch: 1. Construct the multiversion serialization graph: draw an edge `Ti → Tj` whenever `Ti` writes a version that `Tj` reads, or whenever `Tj` writes a version immediately following one that `Ti` read (a read-then-overwrite ordering constraint), or whenever `Ti` writes before `Tj` writes to the same item. 2. Every such edge, by construction of the read/write rules above, points from the transaction with the smaller timestamp to the transaction with the larger timestamp. This is because: the read rule always selects a version whose writer has a smaller or equal timestamp; the write-rejection rule specifically aborts any transaction whose write would have to be inserted before a read already performed by a larger-timestamp transaction, which is precisely the scenario that would have created a "backward" edge. 3. A graph in which every edge respects a fixed total order (the timestamp order) cannot contain a cycle, since a cycle would require at least one edge violating that order. 4. Acyclicity of the (multiversion) serialization graph is a sufficient condition for serializability — this is the multiversion generalization of the classical serializability theorem used for single-version scheduling. Hence every schedule produced by MV-TO is serializable, equivalent to the serial order given by transaction timestamps. Note the subtlety versus single-version timestamp ordering: MV-TO never rejects a read, only late writes, because the existence of old versions gives every read a valid target. This is the formal reason "readers never block or abort due to writers" under pure MV-TO.

The honest tradeoff. Full MV-TO is rarely implemented exactly as above because assigning a fresh, globally meaningful timestamp per operation and checking `R-timestamp` on every write is expensive and can cause frequent aborts under contention. Real systems instead implement snapshot isolation (SI): each transaction reads a consistent snapshot of the database as of its start time (exactly per the MV-TO read rule), but write-write conflicts are detected only at commit time using a simpler first-committer-wins rule rather than fine-grained `R-timestamp` bookkeeping. ``` function SI_COMMIT(T): for each item X that T wrote: if some transaction T' committed after T's snapshot began, and T' also wrote X: abort(T) // first-committer-wins // otherwise commit T; its writes become new versions // visible to transactions whose snapshot starts after now ``` Snapshot isolation guarantees that no transaction ever sees another transaction's uncommitted writes, and no transaction overwrites a concurrently-modified item without detection — but it does not guarantee full serializability. The classic counterexample is write skew: two transactions each read overlapping data, each independently satisfy a global constraint based on what they read, and each writes to disjoint items — so first-committer-wins never fires, yet the combined effect violates the constraint. This gap between "snapshot isolation" and "true serializability" is precisely the subject that the study of Isolation Levels formalizes in full.

Worked example

Consider a bank account item `X` starting at value `100`. Three transactions arrive with assigned timestamps `TS(T1)=10`, `TS(T2)=15`, `TS(T3)=20`.

StepOperationVersion chain beforeActionVersion chain after
1Init—dummy version `X0`: content=100, W-ts=0, R-ts=0`X0(0,0)`
2`T1` reads `X``X0(0,0)`largest W-ts ≤10 is `X0`; return 100; set R-ts(X0)=10`X0(0,10)`
3`T2` reads `X``X0(0,10)`largest W-ts ≤15 is `X0`; return 100; set R-ts(X0)=15`X0(0,15)`
4`T1` writes `X`=150`X0(0,15)`check R-ts(X0)=15 > TS(T1)=10 → abort T1unchanged, `T1` restarts with new TS
5`T1` restarts as `T1'`, `TS(T1')=25``X0(0,15)`writes `X`=150; largest W-ts ≤25 is `X0`; R-ts(X0)=15 ≤25 OK; create `X1``X0(0,15)`, `X1(25,25)` content=150
6`T3` reads `X``X0(0,15)`, `X1(25,25)`largest W-ts ≤20 is `X0` (since `X1`'s W-ts=25>20); return 100; R-ts(X0)=max(15,20)=20`X0(0,20)`, `X1(25,25)`
7`T2` writes `X`=175`X0(0,20)`, `X1(25,25)`largest W-ts ≤15 is `X0`; check R-ts(X0)=20 > TS(T2)=15 → abort T2`T2` restarts

This trace demonstrates the two essential behaviors. First, `T1`'s original write at step 4 is correctly rejected: `T2` (timestamp 15, larger than `T1`'s 10) already read the pre-write value of `X` in step 3, so inserting `T1`'s write at logical time 10 would silently invalidate a read that has already occurred — the system detects this and aborts `T1` rather than corrupting the serialization order. Second, after `T1` restarts with a larger timestamp (25), its write safely becomes the newest version, and subsequent transactions with timestamps below 25 (like `T3` at time 20) correctly continue to see the older value `100`, exactly matching what a serial execution in timestamp order would have produced. `T2`'s subsequent write attempt at step 7 fails for the same reason `T1`'s did originally — `T3` has by then also read `X0`, extending its `R-ts` beyond `T2`'s timestamp. Note that at no point did any read fail or block; every rejection was of a write, precisely as the correctness argument predicts.

Common pitfalls & traps

PitfallWhy it happensFix
Forgetting write-write conflict checksAssuming "MVCC means no conflicts at all"Writers must still detect concurrent writes to the same item; only read-write conflicts vanish
Unbounded version chain growthNo garbage collection, or one long-running transaction pins the vacuum horizonTrack the oldest active transaction's timestamp; periodically reclaim superseded versions
Treating snapshot isolation as serializableSI avoids dirty reads and lost updates but not write skewUse serializable snapshot isolation (SSI) or explicit predicate locks when true serializability is required
Assigning timestamps non-monotonically in distributed systemsClock skew across nodes breaks the "readers never wait" guaranteeUse hybrid logical clocks or centralized timestamp oracles (as in Spanner-style systems)
Reading a version whose R-timestamp bookkeeping is skipped for performanceSome systems approximate MV-TO with simpler snapshot mechanisms, silently changing guaranteesKnow precisely which isolation level your engine actually implements, not just "MVCC" as a marketing term

Pioneers & history

The theoretical foundation for MVCC emerges from the same timestamp-ordering tradition used for single-version scheduling, extended to versioned histories by researchers studying concurrency control in the late 1970s and early 1980s — most notably work by Philip Bernstein and Nathan Goodman, who formalized multiversion timestamp ordering and proved its serializability properties rigorously, and David Reed, whose 1978 MIT dissertation on multiversion timestamping for a decentralized filesystem is often credited as the first systematic treatment.

Origins · 1 minDavid P. Reed and how this was discovered
DP
David P. Reed
b. 1952 · MIT, multiversion timestamping and decentralized systems

Reed's doctoral research addressed how a distributed store could allow concurrent access without a central lock manager, by keeping historical versions tagged with logical timestamps. His formulation — reads select the latest version not exceeding the reader's timestamp, writes create new versions — is essentially the algorithm still taught today as MV-TO, decades before it became the default engine behavior in commercial and open-source databases.

History
1978
— David Reed's MIT dissertation formalizes multiversion timestamp ordering for decentralized data stores.
1981
— Philip Bernstein and Nathan Goodman publish a rigorous concurrency-control survey formalizing multiversion serializability theory.
1992
— Oracle popularizes multiversion read consistency in commercial relational database engines.
1995
— Berenson, Bernstein, and colleagues formally characterize snapshot isolation and identify write skew as its key gap from serializability.
2000
s — PostgreSQL and later InnoDB (MySQL) adopt MVCC-based storage as their default concurrency mechanism.

Where this matters

MVCC is the default concurrency mechanism inside PostgreSQL, Oracle, MySQL/InnoDB, Microsoft SQL Server (under snapshot isolation), and SQLite's write-ahead-log mode. Every distributed SQL system built for global scale — Google Spanner, CockroachDB, YugabyteDB, TiDB — layers MVCC over a distributed timestamp or hybrid-logical-clock scheme to give transactions consistent snapshots across machines without global locking. Analytics engines that run long read queries against operational data (change-data-capture pipelines, HTAP systems) depend entirely on the "readers never block writers" property to coexist with transactional workloads. Version control systems, append-only log stores, and even blockchain state models echo the same core idea: never destroy history, always append, and let readers choose their vantage point in time. Any system description that begins "reads see a consistent snapshot as of transaction start" is, structurally, an MVCC system, and its precise guarantees are best understood as a point on the spectrum of Isolation Levels running underneath a foundation shared with the Timestamp-Ordering Protocol and standing in direct architectural contrast to Two-Phase Locking.

Ask the tutor: Can you walk through how serializable snapshot isolation (SSI) detects write skew that plain snapshot isolation misses?

Does MVCC eliminate all forms of blocking in a database?

No. MVCC specifically eliminates read-write blocking: a reader never waits for a writer, and a writer never waits for a reader, because each operates on its own version. However, write-write conflicts remain fully possible and must still be detected, either through the `R-timestamp` check in MV-TO or the commit-time check in snapshot isolation. Two transactions attempting to update the same row concurrently will still cause one to abort or wait. MVCC is best understood as removing a specific, very common source of contention (readers versus writers) rather than removing concurrency control altogether.

How is MVCC different from the single-version Timestamp-Ordering Protocol?

Single-version timestamp ordering assigns one physical value per item and rejects any read or write whose timestamp is inconsistent with prior reads or writes on that value, causing frequent aborts of reads. MVCC keeps a full history of versions per item, so a read is redirected to an older version rather than being rejected outright. The two share the same conceptual backbone — using assigned timestamps to define a legal serialization order — but MVCC's key innovation is trading storage for concurrency by never overwriting data that a reader might still legitimately need.

Is snapshot isolation the same thing as MVCC?

No, though they are closely related and often confused. MVCC is the mechanism (storing multiple timestamped versions). Snapshot isolation is a specific isolation level commonly implemented using that mechanism: transactions read a fixed snapshot from their start time and use a simpler commit-time conflict check rather than full MV-TO's fine-grained read-timestamp tracking. A database could in principle implement full MV-TO (achieving true serializability) or a weaker level like read committed, both using multiversioned storage. Snapshot isolation is simply the most common practical middle ground.

What is write skew and why does it matter in practice?

Write skew occurs when two transactions read overlapping data, each verifies a constraint that holds given what it read, and each then writes to a different item — so no single-item write-write conflict is ever detected, yet the combined result violates the intended invariant. A classic example: two doctors on call both check that "at least one doctor is on call" before signing off, each independently satisfying the check, but both sign off simultaneously leaving zero doctors on call. Snapshot isolation permits this because its conflict detection is per-item, not predicate-based, which is why applications with cross-row invariants need extra safeguards.

How does garbage collection decide which versions are safe to delete?

A version can be reclaimed once no currently active or future transaction could possibly need to read it. Concretely, if the oldest active transaction has timestamp `m`, then any version `Xi` can be deleted provided a strictly newer version of the same item exists whose write-timestamp is still at most `m` — meaning every transaction that could still be running would see that newer version instead. Long-running transactions delay this horizon and are the primary practical cause of version bloat in production MVCC systems.

Why do reads never need to abort under MV-TO, when writes sometimes do?

Every item's version chain includes an initial "dummy" version with the smallest possible timestamp, guaranteeing that some version always has a write-timestamp less than or equal to any reader's timestamp. A read simply selects the newest such version — it can always find one. A write, by contrast, might need to be inserted into a position in the chain that a faster (larger-timestamp) transaction has already read past, which is a temporal contradiction that cannot be corrected after the fact, forcing an abort.

Why do most production databases use snapshot isolation instead of implementing textbook MV-TO exactly?

Textbook MV-TO requires updating an `R-timestamp` field on every read and checking it on every write, which adds bookkeeping overhead and creates additional abort scenarios under contention. Snapshot isolation achieves nearly the same practical benefit — consistent, non-blocking reads — with a cheaper commit-time check limited to write-write conflicts. The tradeoff is giving up full serializability in exchange for lower overhead and fewer aborts, a compromise most applications accept, with serializable snapshot isolation available for the workloads that cannot.

Before this

Where this leads