- xmin Born by
- 100
- xmax Retired by
- 0
- ctid Next address
- (0,1) self
MVCC Internals: How PostgreSQL Stores Row Versions
When you change a row, PostgreSQL does not erase the old copy.
It writes a new version and keeps the old one until no one needs it. A plain SELECT can keep reading its snapshot without waiting for someone else’s UPDATE to finish. That is MVCC. Taking FOR UPDATE, hitting a unique violation, or running heavy DDL is a different story, those wait on locks.
See the mechanism
One row. More than one lifetime.
PostgreSQL keeps history as physical tuple versions. First touch the three coordinates, then watch one UPDATE create a chain.
01 / Meet the tuple
A row version carries its own biography.
These are real header fields, stored beside the user columns. Select one to bring its meaning into focus.
xminBorn by
The transaction that created this physical row version.
02 / Watch an UPDATE
UPDATE does not edit the row in place.
It retires one physical version, creates another, then links the two.
Beat 1
The original tuple is alive
Available heap space
Why there is no gap: XID 200 is both the old tuple's xmax and the new tuple's xmin. Before XID 200 is visible, the old version wins; after it commits and is visible, the new version wins.
03 / Change the observer
The snapshot is the visibility judge.
Both versions can exist at once. What changes is which lifetime the reader is allowed to see.
XID 200 is not visible yet
- xmin Born by
- 100
- xmax Retired by
- 200
- ctid Next address
- (0,2)
- xmin Born by
- 200
- xmax Retired by
- 0
- ctid Next address
- (0,2) self
xmin is visible to my snapshotandno visible updater or deleter in xmax(a lock-only xmax does not retire the tuple)=>read this version
Remember: xmin is birth, xmax records what happened next, ctid is the forwarding address, and the snapshot decides which lifetime is visible.
04 / Prove it yourself
Watch the coordinates move in psql.
One tiny table turns the story into evidence. The output below was captured from the PostgreSQL 17.10 lesson lab.
Lab-verified · correctedOpen the psql proof
-- Watch xmin/xmax change across an UPDATE
CREATE TABLE demo (id int primary key, v text);
INSERT INTO demo VALUES (1, 'a');
SELECT xmin, xmax, ctid FROM demo; -- xmax = 0 (live)
UPDATE demo SET v = 'b' WHERE id = 1;
SELECT xmin, xmax, ctid FROM demo; -- new ctid, new xmin
-- The old version remains until VACUUM or page pruning can safely reclaim it.xmin | xmax | ctid
------+------+-------
1142 | 0 | (0,1)
(1 row)
xmin | xmax | ctid
------+------+-------
1143 | 0 | (0,2)
(1 row)Payoff: Compare the two result rows: xmin and ctid both change. A normal SELECT shows the version visible now; the pageinspect query in the lesson below reveals old physical versions too.
Source trailUnder the hood
heapam.cCreates the replacement tuple and retires the old physical version.
htup_details.hCarry xmin, xmax, and ctid through the version chain.
heapam_visibility.cJudges each physical version against the active snapshot.
Source-level guardrails
- xmax is overloaded. DELETE and UPDATE can retire a tuple, while row locks can leave a non-zero lock-only XID or MultiXact that does not make it invisible. HEAP_XMAX_IS_LOCKED_ONLY
- ctid links; it does not judge. Heap scans test tuple visibility directly. HOT-aware index traversal may follow a ctid chain to find a visible member. heap_hot_search_buffer
- Your own transaction also needs command IDs. t_cid and the snapshot's curcid order changes made by different commands inside one transaction. SnapshotData.curcid
- Cleanup is not VACUUM alone. Opportunistic page pruning can reclaim dead versions once no snapshot can still need them. heap_page_prune_opt
Writers maintain tuple headers in heapam.c. Readers call HeapTupleSatisfiesMVCC in heapam_visibility.c with a snapshot; visibility is not decided by SQL text or by locking every SELECT.
What to remember
MVCC means "append new versions, decide visibility at read time." Every UPDATE writes a new tuple and tombstones the old one via t_xmax; the planner and every scan use the snapshot rules to pick which versions you may see. Once you hold that model, vacuum behavior, bloat, transaction-ID wraparound, and isolation levels all follow logically, and the fix for most "mystery bloat" is a long transaction, not a Postgres bug.
Say it out loud
Close the page and explain this to someone who has not read it.
Can you start from heap versions, not the slogan "readers don't block writers", and walk UPDATE → snapshot visibility → hint bits → who blocks VACUUM without losing the thread? If you only have the slogan, you fail the follow-up. If you have the chain, you can take bloat, isolation, and on-call xmin questions from the same model.
Then compare with a full answer
Start on the page, not the acronym. Every live row version is a heap tuple with header fields that matter: t_xmin / t_xmax, and t_ctid when versions chain. UPDATE does not paint over bytes in place, it creates a new tuple and stamps the old one with t_xmax. Concurrent readers keep their snapshots and still see the old version until snapshot rules say otherwise; that is the non-blocking read story, and it is why we pay in versions. Visibility is not "highest XID wins." It is per-snapshot satisfaction of xmin/xmax against committed state and in-progress transactions (HeapTupleSatisfiesMVCC-style logic). After commit, hint bits can record xmin/xmax committed on the tuple so later passes skip repeated clog lookups, which is why a cold SELECT can dirty pages: finishing bookkeeping, not changing your result. Old versions are dead to new snapshots but still on disk until cleanup. VACUUM (and opportunistic pruning) reclaims them only past the global horizon of snapshots still running. Flat n_live_tup with a rising file → versions retained; first checks are long transactions / idle-in-transaction (backend_xmin, xact_start), then vacuum lag on that table, not "MVCC is a leak," not VACUUM FULL as step one. That chain, fork version → snapshot decide → hint-bit touch-up → horizon blocks remove, is what we hire for. HOT, fillfactor, and autovacuum scale_factor hang off it.
It has to connect
- UPDATE is not an in-place edit: new heap tuple; old row gets t_xmax = updater's XID; new row t_xmin = same XID; versions may chain via t_ctid (HOT is a refinement, don't hand-wave it unless asked).
- Visibility is snapshot math, not "the latest row." xmin/xmax vs committed state and in-progress XIDs (HeapTupleSatisfiesMVCC family).
- Hint bits cache commit status on the tuple; a first reader after commit may set them, so a SELECT can dirty pages without changing the SQL result.
- Dead is not free: old versions stay until no snapshot can see them; space returns via prune/VACUUM, not COMMIT.
- Horizon: a long transaction or idle-in-transaction pins xmin; vacuum cannot remove what that snapshot still needs, flat live count, growing file.
- Scope: plain MVCC heap reads are not row locks; FOR UPDATE / unique checks / heavyweight locks are a different chapter, say so if pushed on concurrency.
Where it usually stops short
"MVCC keeps multiple versions so readers don't block writers." True as a poster. Fails the panel: no heap layout, no xmax/xmin, no hint bits, no vacuum horizon. The follow-up ("why is the table 10× on disk with flat row count?") lands on silence or "autovacuum is broken."
Push further
- Walk an UPDATE of one row on the heap. What is written, what is only stamped, what does t_ctid do, and what does not happen to other sessions' snapshots?
- A read-only SELECT dirties buffers or generates write I/O. Name the mechanism. When does it not write?
- pg_class row count is flat; the relation file keeps growing. Explain it in storage terms, then name the first two places you look operationally, not VACUUM FULL as step one.
Read the written reference7 sections · ~490 words · 1 runnable queryOne thing firstHeap tuple headerWhat an UPDATE actually doesWhat the snapshot recordsHint-bit optimizationKeeping version churn under controlScope note (locks are separate)
One thing first
An UPDATE in PostgreSQL is not an edit; it is an insert plus a tombstone. Once that clicks, bloat, vacuum, long-transaction hazards, and isolation levels all stop being surprising. The whole mechanism lives in the on-disk tuple header, understand that struct and you understand MVCC.
Heap tuple header
Every row stored in a heap page is prefixed by a HeapTupleHeaderData struct, defined in src/include/access/htup_details.h. The fields that drive visibility are:
t_xmin, the transaction ID (XID) that inserted this version.t_xmax, the deleting, updating, or locking XID/MultiXactId.t_infomaskdistinguishes a retiring change from lock-only state; non-zero does not by itself mean dead.t_cid, command ID (cmin,cmax, or a combo CID), ordering changes made by different commands inside the same transaction.t_ctid, a(block, offset)pointer to the next version of this row (used for UPDATE chains).t_infomask/t_infomask2, bit flags caching commit status and other metadata.
You can see these directly with the pageinspect extension:
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT lp, t_xmin, t_xmax, t_ctid, t_infomask::bit(16)
FROM heap_page_items(get_raw_page('accounts', 0));lp | t_xmin | t_xmax | t_ctid | t_infomask
-----+--------+--------+---------+------------------
1 | 1140 | 0 | (0,1) | 0000100000000010
2 | 1140 | 0 | (0,2) | 0000100000000010
3 | 1140 | 0 | (0,3) | 0000100000000010
4 | 1140 | 0 | (0,4) | 0000100000000010
5 | 1140 | 0 | (0,5) | 0000100000000010
6 | 1140 | 0 | (0,6) | 0000100000000010
7 | 1140 | 0 | (0,7) | 0000100000000010
8 | 1140 | 0 | (0,8) | 0000100000000010
9 | 1140 | 0 | (0,9) | 0000100000000010
10 | 1140 | 0 | (0,10) | 0000100000000010
11 | 1140 | 0 | (0,11) | 0000100000000010
12 | 1140 | 0 | (0,12) | 0000100000000010
13 | 1140 | 0 | (0,13) | 0000100000000010
14 | 1140 | 0 | (0,14) | 0000100000000010
15 | 1140 | 0 | (0,15) | 0000100000000010
16 | 1140 | 0 | (0,16) | 0000100000000010
17 | 1140 | 0 | (0,17) | 0000100000000010
18 | 1140 | 0 | (0,18) | 0000100000000010
19 | 1140 | 0 | (0,19) | 0000100000000010
20 | 1140 | 0 | (0,20) | 0000100000000010
21 | 1140 | 0 | (0,21) | 0000100000000010
22 | 1140 | 0 | (0,22) | 0000100000000010
23 | 1140 | 0 | (0,23) | 0000100000000010
24 | 1140 | 0 | (0,24) | 0000100000000010
25 | 1140 | 0 | (0,25) | 0000100000000010
26 | 1140 | 0 | (0,26) | 0000100000000010
27 | 1140 | 0 | (0,27) | 0000100000000010
28 | 1140 | 0 | (0,28) | 0000100000000010
29 | 1140 | 0 | (0,29) | 0000100000000010
30 | 1140 | 0 | (0,30) | 0000100000000010
31 | 1140 | 0 | (0,31) | 0000100000000010
32 | 1140 | 0 | (0,32) | 0000100000000010
33 | 1140 | 0 | (0,33) | 0000100000000010
34 | 1140 | 0 | (0,34) | 0000100000000010
35 | 1140 | 0 | (0,35) | 0000100000000010
36 | 1140 |What an UPDATE actually does
An UPDATE is not an in-place edit. In heap_update() (src/backend/access/heap/heapam.c) PostgreSQL:
- Stamps the old tuple's
t_xmaxwith the current transaction's XID, marking it deleted as of this transaction. - Writes a new tuple with
t_xmin= current XID. - Sets the old tuple's
t_ctidto point at the new tuple, forming an update chain.
This is why a heavily updated table accumulates dead tuples and why VACUUM is mandatory: the old versions remain physically present until no transaction can still see them.
What the snapshot records
A snapshot (SnapshotData in src/include/utils/snapshot.h) records xmin (oldest still-running XID), xmax (next XID to be assigned), and the list of in-progress XIDs. That triple is enough to classify every tuple as visible or invisible without any locking.
Hint-bit optimization
Checking commit status via the commit log (pg_xact, formerly pg_clog) on every read would be expensive. PostgreSQL caches the answer in t_infomask using bits such as HEAP_XMIN_COMMITTED and HEAP_XMAX_COMMITTED. The first reader that resolves the status sets the hint bit; later readers skip the commit-log lookup. This is why a plain SELECT can dirty pages and generate I/O, it is writing hint bits back.
Keeping version churn under control
- Bloat is structural, not a bug. Dead versions are the price of lock-free reads;
autovacuumreclaims them. Tune it per table rather than fighting MVCC. - Cleanup is incremental. Opportunistic page pruning can reclaim dead versions during ordinary access without waiting for VACUUM, but only after no snapshot can still need them.
- Long-running transactions hold back cleanup. VACUUM cannot reclaim row versions that an older snapshot might still see in that database. Shared catalogs use a cluster-wide horizon; replication slots and
hot_standby_feedbackcan also delay cleanup. Hunt blockers withpg_stat_activity.backend_xmin. - HOT updates (Heap-Only Tuples) avoid touching indexes when no indexed column changes, keeping the update chain inside one page. Lower
fillfactoron update-heavy tables to make HOT more likely. See theHEAP_HOT_UPDATEDflag.
Scope note (locks are separate)
MVCC removes reader/writer blocking for snapshot reads and non-locking writes. SELECT FOR UPDATE, advisory locks, object locks, and unique-index insertion still go through the lock manager (see the lock-manager lesson). Keep that boundary clear in interviews.
Check it against the source4 citations in postgres/postgres · file, symbol and line verified on REL_17_STABLE
Primary symbol: heap_update · line 3346
Primary symbol: HeapTupleSatisfiesMVCC · line 960
Primary symbol: HeapTupleHeaderData · line 153
Primary symbol: SnapshotData · line 142
Anchored to postgres/postgres on REL_17_STABLE and cross-checked against the manual for PostgreSQL 15–18. Every query here was run and its output captured on a throwaway PostgreSQL 17.10 lab; corrections are noted inline. Ch. 13 Concurrency Control in the official docs →
Connected
Where this lesson sits
What comes before, after, and alongside it.
Part of these pathways
Same learning track
Finished a free lesson?
Pro opens the rest of the engine course
You felt one mechanism. Pro is the full bodies, interview depth, and the tracks that build on this session.