Multi-Version Concurrency Control (MVCC) is the dominant concurrency control mechanism in modern database systems. The core idea is deceptively simple: instead of blocking readers when a writer modifies a row, the database maintains multiple versions of each row, each tagged with a transaction timestamp. Readers see a consistent snapshot of the database as of the time their transaction started, without acquiring locks and without being blocked by concurrent writers. Writers create new versions rather than overwriting in place.
MVCC solves a fundamental tension in database design: readers and writers should not block each other. Traditional lock-based concurrency control forces readers to wait for writers (and vice versa), creating contention that degrades throughput under concurrent workloads. MVCC eliminates this contention entirely for read operations—a read transaction never waits for a write lock, because it simply reads the version of the row that was current when the transaction began.
In a typical database engine, MVCC provides transaction isolation for concurrent operations on the search index and metadata store. Each write transaction is assigned a monotonically increasing timestamp, and all modifications are tagged with this timestamp. Read transactions receive a snapshot timestamp and see only versions committed before that timestamp. This enables lock-free reads while maintaining serializable isolation semantics through conflict detection at commit time.
In an MVCC system, each logical row (or key) may have multiple physical versions. These versions are linked in a chain—either newest-to-oldest (N2O) or oldest-to-newest (O2N)—and each version carries metadata: the transaction ID that created it (begin_ts) and the transaction ID that deleted or superseded it (end_ts). A version is visible to a transaction with timestamp T if begin_ts ≤ T < end_ts.
Under snapshot isolation, each transaction operates on a consistent snapshot of the database as of the transaction’s start time. The transaction sees all changes committed before its snapshot timestamp and none committed after. This provides a repeatable read guarantee without holding any read locks. However, snapshot isolation does not prevent all anomalies—write skew is still possible unless additional mechanisms (like serializable snapshot isolation) are employed.
MVCC requires a global ordering of transactions. This is typically achieved with a monotonically increasing counter (logical clock) or a hybrid logical clock (HLC) in distributed systems. When a transaction starts, it receives a read_ts (snapshot timestamp). When it commits, it receives a commit_ts that is strictly greater than any previously assigned timestamp. All versions created by the transaction are stamped with this commit_ts.
The visibility check is the core of MVCC. For a transaction with snapshot timestamp T, a version is visible if and only if:
T (i.e., begin_ts ≤ T).T (i.e., end_ts > T or end_ts is unset).While MVCC allows concurrent reads and writes without blocking, two transactions cannot both write to the same key. When a transaction attempts to commit a write to a key that was also modified by another transaction that committed after the writer’s snapshot timestamp, a write–write conflict is detected and one transaction must abort. This is the “first committer wins” rule.
Old versions that are no longer visible to any active transaction are dead weight—they consume storage and slow down version chain traversals. Garbage collection (GC) identifies the oldest active snapshot timestamp and reclaims all versions that are invisible to every current and future transaction. In PostgreSQL this is called VACUUM; in other systems it may be called purge or compaction.
A long-running read transaction holds back garbage collection for the entire system. If transaction T1 started an hour ago and is still reading, no version created after T1’s snapshot can be garbage collected, even if thousands of subsequent transactions have committed. This is a major operational concern: a single forgotten open transaction can cause unbounded storage growth and version chain bloat.
MVCC implementations fall into two broad categories based on how they store versions:
A database engine using an LSM-tree-based storage layer typically employs an append-only approach. Each key-value pair is written with its transaction timestamp as part of the key encoding: [user_key][timestamp] (with timestamps encoded in descending order so that the most recent version sorts first). A point read for a key at snapshot timestamp T seeks to [user_key][T] and reads the first entry found, which is the most recent version visible to the transaction.
A typical MVCC transaction lifecycle in such a database engine:
Standard snapshot isolation permits a class of anomalies called write skew, where two transactions read overlapping data, each makes a write based on what it read, and the combined effect violates an integrity constraint. Serializable Snapshot Isolation (SSI), introduced by Cahill et al. (2008) and implemented in PostgreSQL 9.1+, detects dangerous patterns of read–write dependencies and aborts one of the conflicting transactions. SSI adds a modest overhead to track read sets but provides full serializability without the locking overhead of traditional two-phase locking.
MVCC is inherently optimistic: transactions proceed without blocking, and conflicts are detected only at commit time. This maximizes concurrency under low-contention workloads. Under high contention (many transactions writing to the same keys), optimistic schemes suffer from high abort rates. Some systems offer hybrid approaches: use MVCC for reads and selective locking (e.g., SELECT FOR UPDATE) for known-contentious writes.
A database engine using MVCC allows search queries to execute against a consistent snapshot of the index while concurrent write operations (document inserts, updates, deletes) modify the index in the background. A search query that starts at time T1 sees a frozen view of the index and is unaffected by a bulk import that commits at T2. This is critical for search engines where query latency must be predictable regardless of write activity.
Because MVCC retains old versions, it naturally supports point-in-time queries: “what was the value of key X at time T?” This is useful for auditing, debugging, and regulatory compliance. Some systems (like CockroachDB) expose this as a SQL feature (AS OF SYSTEM TIME), allowing users to query historical snapshots directly.
Taking a consistent backup of a live database traditionally required either stopping writes or acquiring a global lock. With MVCC, a backup process simply opens a long-running read transaction at a specific snapshot timestamp and streams all data visible at that timestamp. Writes continue unimpeded during the backup. PostgreSQL’s pg_dump and MySQL’s mysqldump --single-transaction both rely on this property.
MVCC extends naturally to distributed databases. Google Spanner uses TrueTime (GPS- synchronized clocks) to assign globally meaningful timestamps, enabling snapshot reads across datacenters without coordination. CockroachDB uses a hybrid logical clock (HLC) to achieve similar semantics. The version visibility rules remain the same; only the timestamp source changes.
In LSM-tree-based storage engines, MVCC versions are naturally ordered by encoding the timestamp as a suffix of the key. A descending timestamp encoding ensures that a seek to [key][snapshot_ts] lands on the most recent visible version. During compaction, the engine can drop versions that are older than the oldest active snapshot, performing garbage collection as a byproduct of the normal compaction process.
Visibility checks require knowing whether a version’s creating transaction has committed. A transaction status table (or commit log) maps transaction IDs to their status (active, committed, aborted) and commit timestamps. This table is accessed on every read, so it must be fast—typically an in-memory hash map or concurrent skip list, with periodic persistence to disk.
To detect write–write conflicts efficiently, the engine maintains a write intent table that records which keys have pending (uncommitted) writes and which transaction owns them. When a transaction attempts to write a key that already has a pending intent from another transaction, it can either wait for the other transaction to complete or immediately abort, depending on the conflict resolution policy.
This example implements a simplified in-memory MVCC store with snapshot reads, versioned writes, and conflict detection, similar to the patterns used in production database engines.
package mvcc
import (
"fmt"
"sync"
"sync/atomic"
)
// Version represents a single version of a key-value pair.
type Version struct {
Value []byte
BeginTS uint64 // transaction that created this version
EndTS uint64 // transaction that superseded this version (0 = current)
Deleted bool // tombstone marker
}
// Store is a simplified MVCC key-value store.
type Store struct {
mu sync.RWMutex
versions map[string][]*Version // key -> version chain (newest first)
clock uint64 // monotonic timestamp counter
active map[uint64]bool // active transaction IDs
}
// NewStore creates an empty MVCC store.
func NewStore() *Store {
return &Store{
versions: make(map[string][]*Version),
active: make(map[uint64]bool),
}
}
// Begin starts a new transaction and returns its snapshot timestamp.
func (s *Store) Begin() *Txn {
ts := atomic.AddUint64(&s.clock, 1)
s.mu.Lock()
s.active[ts] = true
s.mu.Unlock()
return &Txn{
store: s,
readTS: ts,
writeSet: make(map[string]writeEntry),
}
}
type writeEntry struct {
value []byte
deleted bool
}
// Txn represents an MVCC transaction.
type Txn struct {
store *Store
readTS uint64
writeSet map[string]writeEntry
}
// Get reads the value of a key at the transaction's snapshot timestamp.
func (t *Txn) Get(key string) ([]byte, error) {
// Check the local write set first.
if entry, ok := t.writeSet[key]; ok {
if entry.deleted {
return nil, fmt.Errorf("key not found: %s", key)
}
return entry.value, nil
}
t.store.mu.RLock()
defer t.store.mu.RUnlock()
chain := t.store.versions[key]
for _, v := range chain {
// Version is visible if: created before our snapshot,
// and not yet superseded (or superseded after our snapshot).
if v.BeginTS <= t.readTS &&
(v.EndTS == 0 || v.EndTS > t.readTS) {
if v.Deleted {
return nil, fmt.Errorf("key not found: %s", key)
}
return v.Value, nil
}
}
return nil, fmt.Errorf("key not found: %s", key)
}
// Set stages a write in the transaction's write buffer.
func (t *Txn) Set(key string, value []byte) {
t.writeSet[key] = writeEntry{value: value}
}
// Delete stages a deletion in the transaction's write buffer.
func (t *Txn) Delete(key string) {
t.writeSet[key] = writeEntry{deleted: true}
}
// Commit validates and applies the transaction's writes.
func (t *Txn) Commit() error {
commitTS := atomic.AddUint64(&t.store.clock, 1)
t.store.mu.Lock()
defer t.store.mu.Unlock()
// Check for write-write conflicts.
for key := range t.writeSet {
chain := t.store.versions[key]
if len(chain) > 0 {
latest := chain[0]
// Conflict: another txn wrote this key after our snapshot.
if latest.BeginTS > t.readTS {
delete(t.store.active, t.readTS)
return fmt.Errorf("write conflict on key %s", key)
}
}
}
// Apply all writes.
for key, entry := range t.writeSet {
// Mark the previous version as superseded.
chain := t.store.versions[key]
if len(chain) > 0 && chain[0].EndTS == 0 {
chain[0].EndTS = commitTS
}
// Prepend the new version.
newVersion := &Version{
Value: entry.value,
BeginTS: commitTS,
Deleted: entry.deleted,
}
t.store.versions[key] = append(
[]*Version{newVersion}, chain...,
)
}
delete(t.store.active, t.readTS)
return nil
}
This example demonstrates the core visibility logic for MVCC reads, showing how a transaction selects the correct version from a version chain.
from dataclasses import dataclass
from typing import Optional
@dataclass
class Version:
"""A single version of a key-value pair."""
value: bytes
begin_ts: int # timestamp of creating transaction
end_ts: int = 0 # timestamp of superseding transaction (0 = current)
deleted: bool = False
def find_visible_version(
chain: list[Version],
snapshot_ts: int,
committed_txns: set[int],
) -> Optional[Version]:
"""
Find the version visible to a transaction with the given snapshot.
A version is visible if:
1. Its creating transaction committed before our snapshot.
2. It has not been superseded by a transaction that committed
before our snapshot.
Args:
chain: Version chain, newest first.
snapshot_ts: The reader's snapshot timestamp.
committed_txns: Set of committed transaction timestamps.
"""
for version in chain:
# Skip versions from uncommitted transactions.
if version.begin_ts not in committed_txns:
continue
# Skip versions created after our snapshot.
if version.begin_ts > snapshot_ts:
continue
# Skip versions already superseded before our snapshot.
if (version.end_ts != 0
and version.end_ts in committed_txns
and version.end_ts <= snapshot_ts):
continue
# This is the visible version.
if version.deleted:
return None # Key was deleted at this snapshot.
return version
return None # Key did not exist at this snapshot.
# Example: simulating concurrent transactions.
chain = [
Version(b"v3", begin_ts=30, end_ts=0), # current
Version(b"v2", begin_ts=20, end_ts=30), # superseded by ts=30
Version(b"v1", begin_ts=10, end_ts=20), # superseded by ts=20
]
committed = {10, 20, 30}
# Transaction at snapshot 25 sees v2 (ts=20 committed, ts=30 not yet).
v = find_visible_version(chain, snapshot_ts=25, committed_txns=committed)
assert v is not None and v.value == b"v2"
# Transaction at snapshot 35 sees v3 (latest committed version).
v = find_visible_version(chain, snapshot_ts=35, committed_txns=committed)
assert v is not None and v.value == b"v3"
# Transaction at snapshot 5 sees nothing (no version existed yet).
v = find_visible_version(chain, snapshot_ts=5, committed_txns=committed)
assert v is None
This is the fundamental invariant of MVCC. By maintaining multiple versions of each row and providing each transaction with a consistent snapshot, the database eliminates the most common source of contention in concurrent workloads. This property makes MVCC the default concurrency control mechanism in nearly every modern relational and NoSQL database.
MVCC trades lock contention for storage and cleanup overhead. Every update creates a new version, and old versions must eventually be reclaimed. The efficiency of garbage collection—whether it’s PostgreSQL’s VACUUM, InnoDB’s purge thread, or LSM compaction—determines the true cost of MVCC under write-heavy workloads. Long-running transactions exacerbate this by preventing GC from reclaiming any versions.
In MVCC, the transaction timestamp determines everything: which versions are visible, which writes conflict, and which versions can be garbage collected. In single-node systems, a simple atomic counter suffices. In distributed systems, achieving a globally consistent timestamp ordering—through TrueTime, HLC, or Lamport clocks—is one of the hardest problems in database engineering.