PostgreSQL Internals: Heap, Tuples, Shared Buffers, WAL, VACUUM
PostgreSQL keeps every row version inside the table's own 8 KB heap pages, stamps each one with the transaction ids that created and deleted it, and pays for that simplicity with dead tuples, VACUUM and transaction-id wraparound — this is the general storage, MVCC and durability machinery as one engine actually built it.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- Problem
Rows must be updatable by concurrent transactions without readers blocking writers, must survive a crash the instant COMMIT returns, and must be found in a handful of page reads among millions.
↓ - Naive solution
Overwrite the row in place under a lock, write the page to disk on commit, keep a separate structure for old versions so readers can see the past.
↓ - Why it breaks
In-place overwrite means every reader waits for every writer on the same row; a crash mid-page-write leaves a torn 8 KB page; and a separate old-version store is a second thing to keep consistent, fsync and garbage-collect.
↓ - Better idea
Never overwrite. Write the new version next to the old one, tag both with transaction ids, let each reader decide visibility from a snapshot — and log intent to a sequential file before touching any page.
↓ - Internal mechanism
Heap pages with a line-pointer array and tuples that carry t_xmin, t_xmax and t_ctid; a snapshot of running xids per statement or transaction; a write-ahead log addressed by LSN; a buffer pool of 8 KB pages; and VACUUM to reclaim versions no snapshot can see.
↓ - Trade-offs
Dead tuples bloat tables and indexes until VACUUM runs; every non-HOT UPDATE writes a new entry in every index; 32-bit xids must be frozen before they wrap; and a long-open transaction pins old versions for everyone.
↓ - Real database
PostgreSQL, since 6.5 (1999) for MVCC. Every structure below can be inspected with
pageinspect,pg_buffercache,pg_stat_*andpg_stats— nothing here is hidden.
Choose your depth
The same mechanism at four altitudes. Start where you are; come back deeper.
A PostgreSQL table is a heap: a file of 8 KB pages with no order. An UPDATE writes a new copy of the row and marks the old one as ended by your transaction. Readers pick the copy their snapshot allows. DELETE only marks. A background process — VACUUM — later removes copies nobody can see.
Durability comes from the write-ahead log: the change is written to a sequential log and fsynced before COMMIT returns; the page itself is written later. Indexes are separate B-trees whose leaves store the physical address of a tuple.
General concept → PostgreSQL structure
Everything below is a specific implementation of a general mechanism taught elsewhere in the internals layer. Keep the two apart: "a slotted page" is a concept; "an 8 KB heap page with pd_lower/pd_upper and ItemIdData line pointers" is PostgreSQL. "Multi-version concurrency control" is a concept; "xmin/xmax stamped on the tuple itself, with VACUUM as the garbage collector" is PostgreSQL's choice, and InnoDB made a different one (InnoDB Internals: Clustered Index, Buffer Pool, Redo, Undo, Locks). The matrix is the map; each row is a section of this lesson and names the view or extension that lets you look at the real thing.
| General concept | PostgreSQL structure | Where to see it |
|---|---|---|
| Record / slotted page (Slotted Pages) | Heap tuple in an 8 KB heap page; line-pointer array; tuples grow from the end | pageinspect: heap_page_items(get_raw_page('t', 0)), page_header(...) |
| Version chain (MVCC Internals: Version Chains and Snapshots) | t_xmin, t_xmax, t_ctid, hint bits in t_infomask; HOT chains inside a page | SELECT xmin, xmax, ctid, * FROM t; pageinspect shows t_infomask |
| Buffer pool (The Buffer Pool) | shared_buffers: fixed array of 8 KB buffers, clock-sweep replacement, bgwriter + checkpointer write dirty pages | pg_buffercache; pg_stat_io; blks_hit / blks_read in pg_stat_database |
| B+ tree index (B+ Tree Internals: Pages, Splits, Merges) | nbtree: Lehman–Yao B-tree, right-links + high keys, (key, TID) in leaves, deduplication | pageinspect: bt_metap, bt_page_stats, bt_page_items |
| Write-ahead log (Write-Ahead Logging) | WAL segments in pg_wal/, records addressed by LSN, full-page images after checkpoint | pg_current_wal_lsn(), pg_waldump, pg_stat_wal |
| Checkpoint / recovery (Crash Recovery) | Checkpointer writes dirty buffers, records redo LSN in pg_control | pg_stat_checkpointer (16+; pg_stat_bgwriter before), pg_controldata |
| Garbage collection of old versions (UPDATE, DELETE and Dead Tuples) | VACUUM / autovacuum: prune HOT chains, remove dead tuples, update free-space map and visibility map, freeze xids | pg_stat_user_tables: n_dead_tup, last_autovacuum; pg_stat_progress_vacuum |
| Planner statistics (Cost-Based Optimization) | ANALYZE samples 300 × default_statistics_target rows into pg_statistic | pg_stats (MCV lists, histograms, correlation, n_distinct) |
The heap and the 8 KB page
A table is a heap: one or more 1 GB files (base/<db>/<relfilenode>, .1, .2 …) of 8 KB pages — 8192 bytes, compiled in, and the unit of every read, write, lock and WAL full-page image. Pages have no order among themselves and rows have no order inside them; a row is identified by its TID (block, offset), which is what ctid shows and what every index leaf stores. Because there is no clustered order, INSERT is cheap — the free-space map names a page with room, and the tuple goes wherever pd_upper allows.
Inside the page: a 24-byte header, a growing array of 4-byte line pointers (ItemIdData) from the front, tuples from the back, and free space between pd_lower and pd_upper. Indexes address the line pointer, never the byte offset, so VACUUM can compact tuples within the page without touching a single index. pd_lsn holds the LSN of the last WAL record that modified the page — recovery compares it to the record it is replaying and skips the page if it is already newer.
offset 0 +--------------------------------------------------------------+
| PageHeaderData (24 B) |
| pd_lsn 0/1A2B3C4 pd_checksum pd_flags |
| pd_lower 40 pd_upper 8000 pd_special 8192 pd_prune_xid |
offset 24 +--------------------------------------------------------------+
| lp 1: off 8136 len 56 LP_NORMAL <- ItemIdData, 4 B each |
| lp 2: off 8080 len 56 LP_NORMAL |
| lp 3: off 8000 len 80 LP_NORMAL |
| lp 4: LP_REDIRECT -> lp 3 (HOT chain head, pruned) |
pd_lower 40 +--------------------------------------------------------------+
| |
| free space = pd_upper - pd_lower = 7960 B |
| |
pd_upper 8000 +--------------------------------------------------------------+
| tuple 3: hdr 23 B | null bitmap | id=3 name='carla' score=30 |
| tuple 2: hdr 23 B | null bitmap | id=2 name='bo' score=20 |
| tuple 1: hdr 23 B | null bitmap | id=1 name='ada' score=10 |
offset 8192 +--------------------------------------------------------------+ (pd_special: unused for heap; b-tree pages keep links here)The tuple header: xmin, xmax, ctid, infomask
Every tuple begins with a 23-byte HeapTupleHeaderData: `t_xmin` — the xid that inserted it; `t_xmax` — the xid that deleted or updated it, or 0; `t_cid` — the command number within that transaction (so a statement does not see its own earlier rows in the same transaction); `t_ctid` — the TID of the next version of this row, or its own TID if it is the latest; `t_infomask`/`t_infomask2` — flag bits; `t_hoff` — where the data starts. The visibility rule a snapshot applies to each tuple is short: xmin must be committed and not in the snapshot's in-progress set; xmax must be 0, aborted, or in progress / after the snapshot. UPDATE is literally DELETE + INSERT with a ctid link between them.
Hint bits save the commit-log lookup: the first transaction to examine a tuple after its xmin committed sets HEAP_XMIN_COMMITTED (or HEAP_XMIN_INVALID), and likewise for xmax. Setting a hint bit dirties the page — the reason a read-only SELECT after a bulk load generates writes. HOT: when an UPDATE changes no indexed column and the new version fits on the same page, the old tuple gets HEAP_HOT_UPDATED, the new one HEAP_ONLY_TUPLE, and no index is touched; the index still points at the chain head, whose line pointer becomes LP_REDIRECT when the head is pruned. Leave 10–20 % free per page (fillfactor = 90) on update-heavy tables and HOT applies far more often.
index entry (email='ada') -> TID (0,1) (0,1) t_xmin 101 t_xmax 105 t_ctid (0,4) HEAP_HOT_UPDATED dead once xid 105 is old enough (0,4) t_xmin 105 t_xmax 109 t_ctid (0,5) HEAP_ONLY_TUPLE|HOT_UPDATED dead (0,5) t_xmin 109 t_xmax 0 t_ctid (0,5) HEAP_ONLY_TUPLE live: the version every new snapshot sees snapshot taken at xid 107 (105 committed, 109 not yet): sees (0,4) snapshot taken at xid 112: sees (0,5) VACUUM (or opportunistic pruning): lp 1 -> LP_REDIRECT to lp 5; lp 4 freed; index untouched
Shared buffers and the B-tree
shared_buffers is a fixed array of 8 KB slots (default 128 MB; 25 % of RAM is the usual starting point) with a hash table from (relfilenode, fork, block) to slot, pin counts, a dirty flag, and clock-sweep replacement — each buffer has a usage count up to 5, decremented as the clock hand passes, evicted at zero (Buffer Replacement: LRU, Clock and Scan Resistance). A sequential scan of a table larger than a quarter of the pool uses a 256 KB ring buffer so it cannot flush the working set. Dirty pages are written by the background writer ahead of demand and by the checkpointer at checkpoints; a backend only writes a page itself when it must evict a dirty one. Underneath sits the kernel page cache, so a "miss" in pg_stat_database.blks_read is often still a memory read; pg_stat_io (16+) separates the two.
The default index is nbtree, a Lehman–Yao B-tree: every page carries a right-link to its sibling and a high key bounding what it may contain, so a reader that arrives at a page just split can step right instead of restarting, and only one page needs to be locked at a time. Leaves hold (key, TID) items; internal pages hold (separator, child block). Fanout for an 8-byte key is roughly 400 per page, so three levels cover ~64 million rows and the root and inner level are always in shared buffers. Since PostgreSQL 12 the heap TID is a trailing key column, which makes duplicate keys ordered and lets 13+ deduplicate equal keys into posting lists; 14+ bottom-up deletion removes index entries for dead tuples on the way to a split, blunting the non-HOT-update bloat problem.
1CREATE EXTENSION IF NOT EXISTS pageinspect;2CREATE EXTENSION IF NOT EXISTS pg_buffercache;3 4-- the tuples on block 0, with their headers5SELECT lp, lp_flags, t_xmin, t_xmax, t_ctid, t_infomask, t_infomask26FROM heap_page_items(get_raw_page('users', 0));7 8-- how many of this table's pages are in shared_buffers, and how many are dirty9SELECT count(*) AS buffers, count(*) FILTER (WHERE isdirty) AS dirty10FROM pg_buffercache b JOIN pg_class c ON b.relfilenode = pg_relation_filenode(c.oid)11WHERE c.relname = 'users';12 13-- the b-tree: height, and what one leaf page holds14SELECT * FROM bt_metap('users_pkey');15SELECT itemoffset, ctid, itemlen, data FROM bt_page_items('users_pkey', 1) LIMIT 5;Transaction ids, wraparound and freezing
Transaction ids are 32-bit and allocated from a global counter (pg_current_xact_id()); a read-only transaction takes none. Comparison is circular: xid A is "older" than B if it lies in the 2³¹ ids before B. That works only while no visible tuple is more than ~2 billion transactions old — beyond that its xmin would appear to be in the future and the row would vanish. VACUUM prevents it by freezing: any tuple whose xmin is older than vacuum_freeze_min_age (50 M by default) gets HEAP_XMIN_FROZEN and is thereafter visible to every snapshot. Once a table's oldest unfrozen xid reaches autovacuum_freeze_max_age (200 M), an anti-wraparound autovacuum runs whether or not the table is otherwise busy.
If freezing cannot keep up — VACUUM disabled, a table too large to finish, a prepared transaction or replication slot pinning the horizon — the server first warns at 40 M xids of headroom, then at ~3 M refuses new xids entirely and must be vacuumed in single-user mode. The metric to graph is age(datfrozenxid) per database and age(relfrozenxid) per table; 64-bit xids exist only in the per-page epoch and in xid8, not in tuple headers. Multixact ids (used when several transactions lock one row) have their own 32-bit space and their own wraparound.
1SELECT datname, age(datfrozenxid) AS xid_age,2 round(100.0 * age(datfrozenxid) / 2147483647, 1) AS pct_of_limit3FROM pg_database ORDER BY 2 DESC;4 5SELECT relname, age(relfrozenxid) AS xid_age, n_dead_tup, last_autovacuum6FROM pg_class c JOIN pg_stat_user_tables s ON s.relid = c.oid7ORDER BY 2 DESC LIMIT 10;WAL, LSNs and checkpoints
Every change to a page is first described by a WAL record appended to pg_wal/ in 16 MB segments; the record's address is its LSN — a 64-bit byte position, printed as 0/1A2B3C4. The rule is the general one (Write-Ahead Logging): the record must be on disk before the page it describes, and COMMIT returns only after the commit record is flushed (synchronous_commit = on; off trades a few hundred milliseconds of durability for a large throughput gain on small transactions). Each page stores the LSN of its last change in pd_lsn, which is how a buffer knows it may not be written before the WAL up to that LSN is.
A checkpoint (every checkpoint_timeout, default 5 min, or when max_wal_size fills) writes all dirty buffers, then records the redo point in pg_control; recovery starts there and replays forward. The first modification of a page after a checkpoint writes a full-page image into WAL (full_page_writes) so a torn 8 KB page can be reconstructed — the reason WAL volume spikes right after each checkpoint and the reason InnoDB needed a doublewrite buffer instead. LSNs are the coordinate system for everything else: replication lag is a difference of two LSNs (Replication Internals: WAL Shipping, LSNs, Lag and Failover), pg_rewind finds the divergence point by LSN, and backups are consistent up to an LSN.
rmgr: Heap len 54 lsn: 0/1A2B3C4 prev 0/1A2B380 tx 4711 desc: HOT_UPDATE off 1 xmax 4711 ; new off 5 xmax 0, blkref #0: rel 1663/16384/16412 blk 0
rmgr: Btree len 72 lsn: 0/1A2B400 prev 0/1A2B3C4 tx 4711 desc: INSERT_LEAF off 17, blkref #0: rel 1663/16384/16420 blk 3 <- only for non-HOT updates
rmgr: Xact len 34 lsn: 0/1A2B448 prev 0/1A2B400 tx 4711 desc: COMMIT 2026-08-25 10:41:07 UTC <- fsync here, then reply to client
pd_lsn of blk 0 becomes 0/1A2B3C4VACUUM, autovacuum and planner statistics
VACUUM walks a table's pages (skipping all-visible ones via the visibility map), prunes HOT chains, marks dead tuples' line pointers LP_DEAD after removing their index entries, compacts each page, updates the free-space map so INSERT can find room, freezes old xids, and records the results in pg_stat_user_tables. It does not shrink the file except for empty pages at the very end; VACUUM FULL rewrites the table under an exclusive lock and is the answer to bloat, not to routine maintenance. The single limit on what VACUUM may remove is the oldest snapshot still running anywhere — pg_stat_activity.backend_xmin — which is why one forgotten idle in transaction session bloats every table in the database.
Autovacuum launches a worker for a table when n_dead_tup > threshold + scale_factor × n_live_tup (defaults 50 + 20 %), or when freeze age demands it; it is throttled by autovacuum_vacuum_cost_delay, which is why it can fall behind a write-heavy table and why the fix is per-table settings (PostgreSQL in Production: Connections, VACUUM, Partitioning, Replication). The same worker runs ANALYZE on a 10 % threshold: it samples 300 × default_statistics_target rows (30,000 at the default 100) and writes per-column null_frac, n_distinct, most-common values with frequencies, a histogram of the rest, and correlation between column order and physical order — the number that decides whether an index range scan is treated as sequential or random I/O. Every estimate in EXPLAIN comes from this table; when rows= in the plan and actual rows= disagree by 100×, pg_stats is where to look first (Cost-Based Optimization).
1SELECT attname, null_frac, n_distinct, correlation,2 most_common_vals[1:3] AS top_values, most_common_freqs[1:3] AS top_freqs3FROM pg_stats WHERE tablename = 'orders';4 5-- dead tuples and vacuum history per table6SELECT relname, n_live_tup, n_dead_tup,7 round(100.0 * n_dead_tup / greatest(n_live_tup, 1), 1) AS dead_pct,8 last_autovacuum, last_autoanalyze9FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 10;10 11-- who is holding the horizon back?12SELECT pid, state, age(backend_xmin) AS xmin_age, now() - xact_start AS open_for13FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY 3 DESC;Key points
- A table is a heap of unordered 8 KB pages; a row is a TID (block, line pointer). Indexes store TIDs, so tuples can be compacted within a page without touching indexes.
- UPDATE = new tuple + xmax on the old one + ctid link. Visibility is decided per tuple from xmin, xmax and the snapshot; hint bits cache the commit-log answer.
- HOT updates (no indexed column changed, room on the page) write no index entries. Fillfactor and narrow indexes are what make HOT apply.
- shared_buffers is a clock-sweep cache of pages on top of the OS cache; nbtree is a Lehman–Yao B-tree with right-links, high keys and TIDs in the leaves.
- WAL records are addressed by LSN; pages carry the LSN of their last change; checkpoints bound recovery and trigger full-page images.
- VACUUM reclaims what no snapshot can see and freezes xids before 32-bit wraparound; ANALYZE feeds pg_stats, which is all the planner ever looks at.
PostgreSQL heap page and tuple headers
Try it in the playground
When to use — and when not
- This design fits when readers must never block writers and the workload has more reads than updates per row, so dead-tuple overhead stays small relative to the benefit of lock-free snapshots.
- When operational transparency matters: every structure is inspectable with SQL and extensions.
- This design fits poorly when rows are updated many times per second on indexed columns with no free space for HOT — every update writes every index and VACUUM cannot keep pace.
- Very long-running transactions alongside heavy churn: one open snapshot pins dead versions for the whole database.
Failure modes
- Autovacuum falling behind on a hot table: n_dead_tup climbs, every scan reads dead tuples, indexes bloat, plans degrade.
- An
idle in transactionsession or an orphaned replication slot holding back the xmin horizon so VACUUM removes nothing. - Transaction-id wraparound: writes refused until an emergency single-user VACUUM completes.
- Stale pg_stats after a bulk load: row estimates off by orders of magnitude, nested loops chosen over hash joins.
- Wide UPDATEs on indexed columns with fillfactor 100: zero HOT, one new index entry per index per update.
Where you meet this
Back up to the practical layer, and across to the rest of Engineer Atlas.
- Operating SystemsPage cache and write-back → shared_buffers + checkpointer + bgwriterPostgreSQL keeps its own cache of 8 KB pages on top of the kernel page cache; both exist and both matter.
- DSAB+ tree → nbtree: Lehman–Yao B-tree with TIDs in the leaves