MVCC — How Postgres Implements Multi-Version Concurrency Control
Instead of locking a row so readers and writers take turns, Postgres keeps multiple physical versions of every row on the heap, each stamped with the transaction IDs that created and destroyed it (xmin/xmax). A transaction carries a snapshot — a frozen notion of "who had committed when I started" — and at read time walks the version chain, showing exactly the version its snapshot may see. The headline guarantee: reading never blocks writing and writing never blocks reading — MVCC removes the read lock, not the write-write exclusive lock.
Key Components
t_xmin- The transaction id that inserted this version — the "born at" stamp. A version is a candidate for visibility only once its
xminis committed and resolved relative to the reader's snapshot. t_xmax- The transaction id that deleted or updated this version — the "died at" stamp;
0means still live. A delete writes nothing but this stamp; the row's bytes stay on the page. t_ctid/ version chain- A TID that points either to the tuple itself or forward to its newer version. An UPDATE links the old version's
ctidto the new one, forming the chain a reader walks to find the version its snapshot may see. - Snapshot
(xmin:xmax:xip_list) - A frozen record of "who had committed when I started":
xmin= lowest still-active txid,xmax= first not-yet-assigned txid,xip_list= txids in progress between them. A txid inxip_listis treated as in-progress for the snapshot's entire life — that frozen world is Snapshot Isolation. - Commit Log (clog /
pg_xact) - A shared-memory array indexed by txid recording
IN_PROGRESS/COMMITTED/ABORTED. The visibility check consults it to decide whether a version'sxminorxmaxtransaction actually committed. - Hint bits
- Cached commit-status flags in
t_infomask(HEAP_XMIN_COMMITTED,HEAP_XMAX_COMMITTED, …). The first reader resolves a tuple against the clog once and stamps the bit so later readers skip the clog — but setting it dirties the page, so a read can trigger a write. - HOT (Heap-Only Tuple)
- An UPDATE optimization: when no indexed column changed and the new version fits on the same page, the old line pointer becomes
LP_REDIRECTto the new slot and no new index entry is written, avoiding index write-amplification.
Concrete Example
The version stamps are real columns you can inspect. Every heap tuple carries its MVCC bookkeeping physically on the page:
-- Inspect the version stamps directly
SELECT ctid, xmin, xmax, * FROM accounts WHERE id = 7;
-- The current transaction's snapshot: xmin:xmax:xip_list
SELECT pg_current_snapshot(); -- 100:104:100,102
Write DML at storage level is nothing more than stamping these fields:
- INSERT →
xmin =my txid,xmax = 0,ctid →self. - DELETE → target's
xmax =my txid. The bytes stay — only a "died at" stamp is written. - UPDATE = DELETE + INSERT atomically → old version gets
xmax =my txid andt_ctid →new version; new version getsxmin =my txid,xmax = 0. Nothing is edited in place — this is the seed of bloat.
After transaction 104 updates row id=7, the heap holds a two-link version chain:
row id=7: (0,1) xmin=100 xmax=104 ctid→(0,2) ← superseded by txn 104
(0,2) xmin=104 xmax=0 ctid→(0,2) ← current version
A reader whose snapshot still counts 104 as in-progress reads version (0,1): the deleter's stamp "doesn't count," so the old version stays visible. A reader whose snapshot begins after 104 committed finds (0,1) deleted-and-visible, so it follows the ctid link to (0,2). Same bytes on disk, two different "current" rows — and neither reader waited on the writer.
Visual Model
Picture MVCC as a ledger you never erase plus a time-stamped reading glass. Every change is a new line stamped "valid from txn X" and, when superseded, "valid until txn Y," with an arrow to its replacement — a delete just writes the until-stamp. Each transaction holds a reading glass frozen to one instant (its snapshot): it reads only the line whose [from, until) window contains that instant, and treats anyone mid-write at that instant as if their ink never dried. Two readers at different frozen instants see different "current" lines and never wait on each other.
Step through an UPDATE building the version chain, then watch two readers with different snapshots each resolve a different visible version from the very same bytes.
Loading…
Deeper — Edge Cases & Gotchas
The visibility check, precisely
For each version, given my snapshot: a version is visible iff (A) its xmin is committed-and-visible-to-me and (B) no xmax that is committed-and-visible-to-me deleted it. "Committed-and-visible-to-me" for a txid resolves in order:
- aborted (per clog) → never happened.
- ≥ snapshot
xmaxor inxip_list→ treated as in progress → not visible. - otherwise (committed before the snapshot, not in flight) → committed & visible.
The read-committed vs repeatable-read difference is only snapshot timing: READ COMMITTED takes a fresh snapshot per statement; REPEATABLE READ / SERIALIZABLE take one snapshot at the first statement. Same engine, different snapshot lifetime.
xmax != 0 as "this row is deleted." ❌
SELECT ctid, xmin, xmax FROM accounts WHERE id = 7;
-- (0,1) 100 104 ← xmax is SET, but is the row gone?
Why it breaks: xmax is only the id of the transaction that attempted the delete/update. Whether it "counts" depends entirely on your snapshot and the clog. If txn 104 is still in your xip_list, or later aborted, the xmax stamp is inert and version (0,1) is fully live for you. Visibility is a per-transaction question answered against a snapshot, never a raw field comparison.
Hint bits — why a plain SELECT can write to disk
Hitting the clog for every tuple on every read is expensive, so the first reader to resolve a tuple's commit status caches it in t_infomask. But setting a hint bit modifies the page → marks it dirty → it gets flushed. So a plain SELECT right after a bulk COPY resolves thousands of tuples against the clog, stamps hint bits, dirties pages, and triggers writebacks — a read causing writes and I/O. First query slow, later ones fast.
BEGIN bloats the whole database.
BEGIN; -- pins a low xmin for hours
-- ... connection sits idle ...
-- meanwhile OldestXmin is dragged down cluster-wide
Why it breaks: VACUUM does not use the per-snapshot visibility check — it uses a stricter global test. It computes a cluster-wide xmin horizon (OldestXmin) = the smallest xmin across every active transaction. A dead tuple is reclaimable only if its xmax is committed and xmax < OldestXmin. An idle transaction pins a low xmin, so every version deleted after it began becomes unreclaimable — across unrelated tables, because the horizon is global. Hence idle_in_transaction_session_timeout.
HOT and pruning — reclaiming space between VACUUMs
When a HOT update applies, the index keeps pointing at the original slot, which redirects to the current version:
index ──▶ slot (0,1)[LP_REDIRECT] ──▶ slot (0,2) HEAP_ONLY (current)
HOT also enables pruning: an ordinary SELECT/UPDATE/INSERT can remove dead versions and collapse redirects within a page, touching no index — partial reclamation ahead of the next VACUUM.
See Also
How Data Is Stored on Disk — Heap Files, Pages & Slots The page-and-slot layer MVCC stamps:ctid is a line-pointer TID, and the slot indirection is exactly what LP_REDIRECT reuses for HOT.
Isolation Levels & Read Anomalies
The timing of the snapshot: per-statement vs per-transaction is the only thing separating READ COMMITTED from REPEATABLE READ on the same engine.
Two-Phase Locking (2PL)
The other half of Postgres's hybrid: MVCC removes read locks, but 2PL still supplies the write-write exclusive lock.
Covering Indexes & Index-Only Scans
Why the index alone can't confirm visibility — the visibility map exists precisely because of the MVCC bookkeeping described here.
Table Bloat & Autovacuum — Reclaiming What MVCC Leaves Behind
The downstream story: dead tuples and the OldestXmin horizon are exactly what VACUUM must clean up.
ACID Properties
Isolation as a tunable dial — MVCC is the mechanism that implements the I in ACID without serializing every reader.
Write-Ahead Log (WAL) — Crash Recovery & the Durability Guarantee
Commit status lives in the clog, but durability of that commit and of the new versions is the WAL's job.
Buffer Pool / Page Cache — How a DB Manages Memory
Why hint-bit writes matter: dirtying a cached page schedules a writeback through the buffer manager.
Sources consulted
- PostgreSQL Documentation — Introduction to MVCC — fetched 2026-06-17
- The Internals of PostgreSQL §5.2 — Tuple Structure — fetched 2026-06-17
- The Internals of PostgreSQL §5.4 — Commit Log (clog) — fetched 2026-06-17
- The Internals of PostgreSQL §5.5 — Transaction Snapshot — fetched 2026-06-17
- The Internals of PostgreSQL §5.6 — Visibility Check Rules — fetched 2026-06-17
- The Internals of PostgreSQL §7.1 — Heap-Only Tuples (HOT) — fetched 2026-06-17
Test Yourself
Snapshot 150:155:150,153; tuple xmin=152, xmax=0; transaction 152 has already committed in wall-clock time. Is the tuple visible?
A read-only SELECT issued right after a bulk COPY causes disk writes. What is the mechanism?
A table has indexes on email, created_at, and status. You run UPDATE users SET last_seen = now() WHERE id = 7 (no index on last_seen). Is this HOT-eligible, and what happens to line pointers and index entries?