Part 2 · 3 chapters · ~18 min

Storage Layout

The cluster on disk (base directories, OIDs, relfilenodes, tablespaces, forks), the 8 KB page byte by byte, heap tuple headers with xmin, xmax, ctid and infomask bits, TOAST, the free space map and visibility map, fillfactor and HOT updates, and reading real pages with pageinspect.

4

Files, forks and the 8 KB page

code
-- where a table lives
SELECT pg_relation_filepath('accounts');     -- base/16384/24601  (database OID / relfilenode)
-- each relation has forks: 24601 (main), 24601_fsm (free space map), 24601_vm (visibility map)
-- files are split into 1 GB segments: 24601, 24601.1, 24601.2 ...

CREATE EXTENSION pageinspect;
SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('accounts', 0));
SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid, t_infomask::bit(16)
FROM heap_page_items(get_raw_page('accounts', 0));

A relfilenode changes when a table is rewritten (VACUUM FULL, CLUSTER, some ALTER TABLEs, TRUNCATE): the old file is dropped and a new one written, which is why those operations need an exclusive lock and double the disk space while they run. Tablespaces are directories elsewhere, symlinked from pg_tblspc.

THE 8 KB HEAP PAGE
line pointers grow down from the header, tuples grow up from the end
PageHeaderData (24 bytes)LSN, checksum, flags, pd_lower, pd_upper, pd_specialline pointers (4 bytes each)offset + length + flags → ctid (page, item)free spacebetween pd_lower and pd_uppertuple 2HeapTupleHeader (23 bytes + null bitmap) + datatuple 1HeapTupleHeader + dataspecial spaceempty for heap; used by index pages
swipe the figure sideways, or tap expand for full screen
1/5
header
Every page starts with a 24-byte header: the LSN of the last WAL record that changed it (so recovery knows whether to replay), a checksum if enabled, and pd_lower and pd_upper, the edges of free space.
page LSN, checksum, free-space boundsthe LSN ties every page to the WAL
5

Tuple headers, TOAST, FSM and VM

header fieldmeaning
t_xmintransaction that inserted this version
t_xmaxtransaction that deleted or locked it (0 if live)
t_cidcommand id within the transaction (for seeing your own earlier statements)
t_ctidpointer to the newer version, or itself if current
t_infomaskhint bits: xmin committed/aborted, xmax committed, has nulls, has varlena, HOT updated, ...

Hint bits cache the commit status of xmin and xmax on the tuple, so later readers do not consult the commit log. Setting them dirties the page: the first SELECT after a bulk load can write a lot, which surprises people.

TOAST: when a row exceeds about 2 KB (TOAST_TUPLE_THRESHOLD), large variable-length values are first compressed (pglz, or lz4 if configured) and, if still too big, moved out of line into the table's TOAST table in ~2 KB chunks, leaving an 18-byte pointer. Selecting only the columns you need avoids detoasting big values at all.

Free space map (FSM, the _fsm fork) records approximately how much free space each page has, so inserts find room quickly. Visibility map (_vm) keeps two bits per page: all-visible (every tuple visible to everyone, so vacuum can skip it and index-only scans need not visit the heap) and all-frozen (no tuple needs freezing).

6

Fillfactor and HOT updates

code
CREATE TABLE accounts (id bigint PRIMARY KEY, owner_id bigint, balance_kobo bigint, updated_at timestamptz)
  WITH (fillfactor = 85);
CREATE INDEX ON accounts (owner_id);         -- do NOT index balance_kobo or updated_at if they change often

SELECT relname, n_tup_upd, n_tup_hot_upd, round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables ORDER BY n_tup_upd DESC LIMIT 10;
HOT UPDATES
heap-only tuples: an update that does not touch any index
t_ctidindex entry→ (0,1)tuple v1(0,1) xmax=200tuple v2(0,2) heap-onlyUPDATE balancetxid 200page pruneredirect (0,1)→(0,2)
swipe the figure sideways, or tap expand for full screen
1/5
the problem
An UPDATE writes a new tuple version. Normally every index on the table must get a new entry pointing to the new version, even if the indexed columns did not change: write amplification.
new version, new entry in every indexten indexes = ten extra writes per update