Why Postgres uses MVCC
Readers shouldn't have to wait for writers, and writers shouldn't have to wait for readers — so Postgres keeps multiple versions of every row.
On this page
The picture version
Six pictures for a reader who has never run two things against a database at once. The prose below fills in the seams the pictures skip.
1 · The problem
A four-minute report, and a customer who orders halfway through it.
2 · The two obvious answers, both bad
Make somebody wait, or let the report contradict itself.
3 · The move
Stop editing rows. Every change writes a new version and dates it.
4 · What that buys
Hand each transaction a timestamp on the way in, and the question answers itself.
5 · What it costs
Old versions pile up, and something has to sweep them.
6 · Keep this card
The whole thing on one index card.
Why it exists
Someone in finance kicks off the monthly revenue report at 9 a.m. It’s a big query; it’ll take four minutes to sweep the orders table. At 9:01, a customer places an order. Follow that one order row for the rest of this post — whether the report sees it, what happens to the row it replaced, and who has to wait for whom.
In the simplest possible database, somebody waits. The report holds read locks and the customer’s checkout hangs; or checkout takes a write lock and the report stalls behind it. Either way the system serializes around contention, and on a busy table that contention dominates everything else.
The frustration is that the report doesn’t actually need the latest value. It needs a consistent one: a view of the orders table that doesn’t shift under its feet halfway through, so it doesn’t count a customer twice or miss them entirely. And the customer’s transaction doesn’t need exclusive access to the universe — it just needs to know whether its change collides with someone else’s.
Multi-Version Concurrency Control breaks that bottleneck by refusing the premise that there is one row. Instead of a single value everyone fights over, the database keeps several versions of the row and shows each transaction the one that was current for it. Readers don’t block writers; writers don’t block readers. The report and the checkout both proceed, and neither is lying.
Why it matters now
You are almost certainly running on this. Postgres,
InnoDB
inside MySQL, Oracle, SQL Server in its snapshot and read-committed-snapshot
modes, CockroachDB, and YugabyteDB all use some flavor of MVCC. Three symptoms you may have already
debugged without naming the cause: SELECT in Postgres not appearing to take
row locks, VACUUM being something you have to think about at all, and a table
whose disk usage climbs while its row count stays flat because an analytics
query has been open for six hours.
That last one is the tell. MVCC is what lets Postgres offer repeatable read without collapsing under load, and it’s the foundation the serializable level is built on — though serializable needs an extra layer on top, which the seams below get into. Either way, the old row versions those levels depend on are real bytes sitting in your table, not an abstraction.
The short answer
MVCC = rows-as-versions + visibility rules per transaction
Picture to keep: a wall of photographs of the same room, each stamped with when it was taken. Nobody edits a photo; changing the room adds a new one. When you walk in, you’re handed the stamp of the most recent photo at that moment, and every question you ask gets answered from it while other people keep taking new ones.
When you write a row in Postgres the old version isn’t overwritten. A new version is written, tagged with the transaction that created it. A reading transaction holds a snapshot — in effect, a record of which transactions had committed at a given instant — and Postgres walks the versions of each row and shows the one that snapshot should see.
So readers and writers don’t fight: they’re looking at different copies. The limit is worth stating right next to the picture, because it’s the thing people get wrong: two transactions writing the same row still collide. MVCC removes read-write contention, not write-write contention.
How it works
Build it from the failure. Follow the order row.
Naive attempt: overwrite the row in place, and let the report take a lock so nothing moves under it. Correct, and it’s what the report needs. Why it breaks: checkout is now blocked for four minutes. Correctness bought with availability, which is the trade the whole design exists to refuse.
Fix: never overwrite. Write a new version and leave the old one. Now the 9:01 write doesn’t destroy what the report is reading, so nobody has to hold a lock to keep their view stable. Why it breaks: if both versions are sitting in the table, every reader now sees the row twice. You’ve replaced blocking with ambiguity.
Fix: stamp every version with who made it, and give every transaction a rule for which stamps count. Each row in Postgres carries two hidden columns:
xmin— the transaction ID that created this row version.xmax— the transaction ID that deleted or superseded it. Zero means nothing has tried; a non-zeroxmaxdoesn’t necessarily mean the row is dead, because that transaction may not have committed — or may have rolled back.
An UPDATE is mechanically an INSERT of a new version plus a stamp on the
old one’s xmax. Both versions sit in the table at once. When a transaction
reads, Postgres asks of each candidate version, roughly: was xmin’s
transaction already committed as of my snapshot, and is there no committed
xmax as of my snapshot? If yes, this is the version you see. That’s the
visibility check, and it’s how the report gets a stable view without taking
a single row lock.
A wrinkle worth knowing, because it surprises people: how often that snapshot
is taken depends on the isolation level. At Postgres’s default READ COMMITTED, each statement gets a fresh snapshot — so a multi-statement
transaction can see the 9:01 order in its second query having missed it in the
first. At REPEATABLE READ and SERIALIZABLE, the snapshot is taken once for
the whole transaction. The four-minute report only gets its consistent view for
free if it’s a single statement, or if it asked for the stronger level.
Why it breaks: superseded versions pile up forever. The row the 9:01 order
replaced becomes unreachable once no live snapshot could still need it, but
nothing has removed it. Fix: collect the garbage in the background. VACUUM, run automatically by
autovacuum,
scans tables and marks dead versions’ space reusable. Note reusable, not
returned to the operating system — plain VACUUM generally doesn’t shrink the
file; that’s VACUUM FULL, which rewrites the table and takes an exclusive
lock.
Why it still breaks: vacuum can only remove a version older than the oldest thing that might still need it. Postgres tracks this as a horizon, and anything that holds the horizon back — a six-hour analytics query, an idle open transaction, a prepared transaction, a lagging replication slot — keeps everything superseded since then alive. The table grows while its logical row count doesn’t. That’s table bloat, and in my experience long-lived transactions are the usual culprit, though they’re not the only way to hold a horizon.
Then there’s transaction ID wraparound. Transaction IDs are 32-bit, so the comparison “was this committed before me?” stops meaning anything once the counter laps. Old row versions have to be frozen — marked as visible to everyone, no comparison needed — before that happens. This is the failure that arrives at 3 a.m. on the table nobody vacuumed for months.
Show the seams
A few things the textbook version of MVCC tends to gloss over:
- Postgres’s flavor is unusual. Oracle and MySQL/InnoDB store old versions in a separate undo log / rollback segment and overwrite the live row in place. Postgres keeps every version inline in the table. That’s why version cleanup is so visible in Postgres specifically: those engines do equivalent work, but the versions live in undo space, so the pressure shows up there rather than as your table file growing. (Their tables can still bloat and fragment for other reasons — this is about where old versions go, not a claim that undo-based engines never grow.)
- MVCC doesn’t make all writes lock-free. If two customers modify the same order row concurrently, one blocks on a row lock or gets a serialization failure. MVCC removes read-vs-write contention only. If your bottleneck is a single hot row being updated by everybody, MVCC does nothing for you.
- Snapshot isolation is not serializability. Postgres’s
REPEATABLE READis snapshot isolation, and snapshot isolation permits write skew: two transactions each read a consistent snapshot, each write something perfectly legal on its own, and the pair together violates an invariant neither could see. (READ COMMITTEDis weaker still, with its own separate anomalies.) For real serializability Postgres layers SSI on top, which tracks reads using predicate locks (SIReadLock) that don’t block anyone — they exist to detect dangerous dependency patterns, and the resolution is to abort a transaction rather than make it wait. Which means serializable Postgres can hand you retry errors on workloads that never deadlock.
There is no crisp public date for when MVCC first entered Postgres specifically — the design traces back to its Berkeley POSTGRES roots in the 1980s, but which release the modern semantics solidified in isn’t cleanly recorded, so this post doesn’t pin a year.
You started with MVCC = rows-as-versions + visibility rules per transaction.
What did this post add? — + someone has to delete the old versions. Versions
and visibility rules explain how the 9 a.m. report and the 9:01 checkout coexist.
They don’t explain VACUUM, bloat, freezing, or wraparound — and those are what
you’ll actually spend time on. Keeping the old row is what makes MVCC work;
being allowed to forget it is what makes MVCC operable, and that permission
only arrives when the last snapshot that could need it goes away.
Check yourself
Before you go — an engineer opens a psql session, runs one SELECT, gets
distracted, and leaves the transaction open (not committed) for two days. Nobody
else is blocked. Is anything wrong?
Answer
Yes, and the absence of blocking is what makes it dangerous. That idle transaction holds a snapshot, and vacuum cannot reclaim any row version that snapshot might still need. So every update and delete across the whole database for two days accumulates as dead rows nobody is allowed to remove. Tables grow, indexes grow, sequential scans get slower, and — if it ran long enough on a busy system — freezing gets held back too, which is the road to a wraparound emergency. The lesson to carry: in MVCC, the expensive thing is not a transaction that holds locks, which is loud and obvious. It’s one that holds a snapshot, which is silent.
And one more — would switching the four-minute revenue report from READ COMMITTED to REPEATABLE READ make bloat better or worse?
Answer
Worse, and the reason is worth being able to derive. At READ COMMITTED each
statement takes a fresh snapshot, so between statements there’s no snapshot
pinning old versions. At REPEATABLE READ one snapshot is held for the whole
four minutes, so nothing superseded during that window can be vacuumed until it
finishes. You’d be buying a genuinely better report — no rows counted twice
across statements — and paying for it in retained dead tuples. That’s the actual
trade, and it’s a good one for a four-minute report and a terrible one for a
six-hour job.
Famous related terms
- Snapshot isolation —
snapshot isolation ≈ MVCC + "read your snapshot, check for conflicts at commit"— the isolation level MVCC most naturally provides; the exact conflict rules are engine-specific, so treat the shorthand as a shape rather than a definition. - VACUUM —
VACUUM ≈ garbage collector for dead row versions— the cost MVCC pushes onto operations. - Optimistic concurrency control —
OCC ≈ "assume no conflict, check at commit"— the philosophical sibling: don’t lock, detect conflicts later. - Write-ahead log (WAL) —
WAL = "log the change before touching the data file"— orthogonal to MVCC but the other half of how Postgres stays correct under crashes.
Going deeper
- Postgres docs, “Concurrency Control” — answers “what exactly does each isolation level promise me?”, including precisely when snapshots are taken and which anomalies survive at each level.
- Bruce Momjian’s “MVCC Unmasked” talk slides — answers “what do the hidden columns look like as a transaction runs?”, walking
xmin/xmaxthrough worked examples with diagrams. - Hellerstein, Stonebraker & Hamilton, Architecture of a Database System — the rabbit hole, answering “why did anyone choose versions over locks in the first place?” by putting MVCC beside the alternatives it beat.