Storage & row versionsFree lessonSource-grounded2 real lab transcripts

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.

Heap page 0slot 1
Physical heap tuple
payloadplan = free

xminBorn by

The transaction that created this physical row version.

Snapshot visibility begins here: transaction 100 must be committed and visible before a reader can see this 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

Transaction 100 created one physical version. No transaction has retired it, and its ctid points home.
Original version(0,1)
xmin Born by
100
xmax Retired by
0
ctid Next address
(0,1) self
plan = free
slot 2

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.

Snapshot A

XID 200 is not visible yet

Snapshot reads this
Version at (0,1)Older lifetime
xmin Born by
100
xmax Retired by
200
ctid Next address
(0,2)
plan = free
Not visible yet
Version at (0,2)Newer lifetime
xmin Born by
200
xmax Retired by
0
ctid Next address
(0,2) self
plan = pro
Compact visibility rule

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
Run in psql
-- 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.
Real output PostgreSQL 17.10
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
01
heap_updateheapam.c

Creates the replacement tuple and retires the old physical version.

02
tuple headershtup_details.h

Carry xmin, xmax, and ctid through the version chain.

03
HeapTupleSatisfiesMVCCheapam_visibility.c

Judges 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

  1. 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?
  2. A read-only SELECT dirties buffers or generates write I/O. Name the mechanism. When does it not write?
  3. 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_infomask distinguishes 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:

Lab-verified
SQL
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));
Real psql output, captured in the lab
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_xmax with 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_ctid to 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; autovacuum reclaims 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_feedback can also delay cleanup. Hunt blockers with pg_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 fillfactor on update-heavy tables to make HOT more likely. See the HEAP_HOT_UPDATED flag.

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

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 →

ShareLinkedInX

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.

FollowSubstackLinkedInnew errors · lab notes · hiring loops