← databases book ⊞ All topics

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 xmin is committed and resolved relative to the reader's snapshot.
t_xmax
The transaction id that deleted or updated this version — the "died at" stamp; 0 means 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 ctid to 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 in xip_list is 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's xmin or xmax transaction 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_REDIRECT to 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:

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.

Step 1 of N
Heap — version chain for row id=7 v1 · ctid (0,1) xmin=100 (born) xmax=0 → still live xmax=104 · ctid → (0,2) ctid v2 · ctid (0,2) xmin=104 (born) xmax=0 → current Reader A snapshot 100:105:104 104 ∈ xip_list → in progress sees v1 Reader B snapshot 106:110: 104 committed before snapshot sees v2

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:

  1. aborted (per clog) → never happened.
  2. ≥ snapshot xmax or in xip_list → treated as in progress → not visible.
  3. 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.

Anti-pattern: reading 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.

Anti-pattern: leaving a transaction idle-in-transaction. One forgotten 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.

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?