Storage
Page layout
Every table and index file is a sequence of 8 kB pages. Inside a page, a small array of pointers grows from the top and the rows themselves grow from the bottom.
In plain words
PostgreSQL never reads or writes a single row on its own. It always moves whole pages of 8 kB, and every table is just a file made of these pages, numbered 0, 1, 2 and so on.
A page works like a notebook filled from both ends. At the top there is a short header, followed by a list of line pointers, small "table of contents" entries that say where each row starts. The rows themselves are written from the bottom of the page upwards. The empty gap in the middle is the free space. When the two ends meet, the page is full and new rows go to another page.
Every row also carries a small header of its own: which transaction created it (xmin), which one deleted or replaced it (xmax), and where its newer version lives (ctid). That is how PostgreSQL can keep several versions of the same row side by side.
A row's address is its ctid: (page number, line pointer number). Indexes store this address to find rows quickly.
For DBAs and operators
The page header (24 bytes)
pd_lsn: LSN of the last WAL record that changed this page. A dirty page may not be written until WAL up to this LSN is flushed (the WAL-before-data rule).pd_checksum: set when data checksums are enabled. initdb enables checksums by default since PostgreSQL 18 (use--no-data-checksumsto opt out).pd_lower/pd_upper: start and end of the free space.pd_upper - pd_loweris what is left.pd_special: start of the special space. It is 8192 (empty) for heap pages; B-tree pages keep sibling links and the level there.pd_prune_xid: oldest XID that might make pruning worthwhile (used by HOT pruning).
Line pointers and tuples
Each line pointer (ItemIdData) is 4 bytes: offset, length and a 2-bit state: LP_NORMAL, LP_REDIRECT (HOT chain start), LP_DEAD (dead; the pointer itself stays until VACUUM has removed the index entries that point to it) or LP_UNUSED. Because indexes point at the line pointer, not the byte offset, PostgreSQL can move tuples inside the page (to defragment it) without touching any index.
A heap tuple starts with a 23-byte header padded to 24: t_xmin, t_xmax, t_cid, t_ctid, t_infomask2 (number of attributes plus HOT flags), t_infomask (hint bits such as HEAP_XMIN_COMMITTED, HEAP_XMAX_INVALID) and t_hoff. A null bitmap follows if any column is NULL. Tuples are aligned to 8 bytes (MAXALIGN), so a 34-byte tuple occupies 40.
Look at a real page with pageinspect
CREATE EXTENSION pageinspect;
CREATE TABLE pl (id int, name text);
INSERT INTO pl VALUES (1,'alice'),(2,'bob'),(3,'carol');
SELECT lsn, lower, upper, special, pagesize
FROM page_header(get_raw_page('pl', 0));
-- lsn | lower | upper | special | pagesize
-- 0/183C660 | 36 | 8080 | 8192 | 8192
SELECT lp, lp_off, lp_len, t_xmin, t_xmax, t_ctid, t_infomask
FROM heap_page_items(get_raw_page('pl', 0));
-- lp | lp_off | lp_len | t_xmin | t_xmax | t_ctid | t_infomask
-- 1 | 8152 | 34 | 765 | 0 | (0,1) | 2050
-- 2 | 8120 | 32 | 765 | 0 | (0,2) | 2050
-- 3 | 8080 | 34 | 765 | 0 | (0,3) | 2050
-- after the next SELECT has set the commit hint bit, t_infomask is 2306
SELECT * FROM heap_tuple_infomask_flags(2306, 0); -- decode the bitsNote lower = 24 + 3 × 4 = 36. After UPDATE pl SET name = 'bobby' WHERE id = 2 the old version gets t_xmax = 766 and t_ctid = (0,4), and the new one appears at lp 4. Its t_infomask2 has HEAP_ONLY_TUPLE set because the update was HOT.
Support notes
- Hint bits are set by the first reader after commit. That read can dirty the page (and with checksums or
wal_log_hints, write a full-page image to WAL if it is the first change to that page since the last checkpoint), which surprises people who see writes after a bulk load. - Corruption reports such as ERROR: invalid page in block 1234 of relation "base/16384/16397" (preceded in the server log by LOG: page verification failed, calculated checksum 20919 but expected 15254) refer to these 8 kB blocks.
amcheck(verify_heapam) can scan a table for header inconsistencies. - A row cannot span pages; large values are moved out of line by TOAST. The maximum row size on a page is about 8160 bytes.
block_sizeis a compile-time constant; practically every build uses 8 kB.
PostgreSQL 18 source trail: bufpage.h defines page layout and line pointers; htup_details.h defines the heap tuple header.
For developers: inside the source
Where it lives
bufpage.c:PageInit(),PageAddItemExtended()(behind thePageAddItem()macro),PageRepairFragmentation(),PageGetHeapFreeSpace(),PageIsVerified(),PageSetChecksumCopy().itemid.h(ItemIdData:lp_off:15,lp_flags:2,lp_len:15) anditemptr.h(ItemPointerData, the ctid:ip_blkid+ip_posid).checksum_impl.h:pg_checksum_page(), an FNV-1a based 16-bit checksum that mixes in the block number;storage/page/READMEexplains when it is computed.hio.candpruneheap.c: putting tuples on pages and compacting them.
Key structures
PageHeaderData ends with the flexible array pd_linp[], so SizeOfPageHeaderData (24) is offsetof(PageHeaderData, pd_linp). pd_flags holds PD_HAS_FREE_LINES, PD_PAGE_FULL and PD_ALL_VISIBLE; pd_pagesize_version stores PG_PAGE_LAYOUT_VERSION (4). In HeapTupleHeaderData the first 12 bytes are a union: t_heap (xmin, xmax, cid/xvac) on disk, t_datum when the tuple is a composite Datum. Read fields through accessors such as HeapTupleHeaderGetRawXmin(), never directly. Limits: MaxHeapTupleSize (8160) and MaxHeapTuplesPerPage (291).
Call flow of an INSERT
heap_insert()→heap_prepare_insert(): fills xmin, infomask, and TOASTs the tuple if needed.RelationGetBufferForTuple(): picks a page (cached target block, then the FSM, then extension) and returns it pinned and exclusively locked.- Inside a critical section,
RelationPutHeapTuple()callsPageAddItem(): reuse an unused line pointer ifPD_HAS_FREE_LINESor append one,MAXALIGNthe size, movepd_lower/pd_upper, copy the tuple, then sett_ctid. MarkBufferDirty(),XLogInsert(RM_HEAP_ID, XLOG_HEAP_INSERT),PageSetLSN().
The checksum is not maintained in memory: FlushBuffer() computes it on a copy with PageSetChecksumCopy() just before the write, and the read-completion callback checks it with PageIsVerified(). Defragmentation happens in heap_page_prune_and_freeze() → PageRepairFragmentation() (compactify_tuples()), which keeps line-pointer numbers, so ctids stay valid.
Observe and experiment
pageinspect:get_raw_page(),page_header(),heap_page_items(),heap_tuple_infomask_flags(),page_checksum()(tests incontrib/pageinspect/sql/page.sql).amcheck'sverify_heapam()and the offlinesrc/bin/pg_checksumstool;pg_surgery(heap_force_kill(),heap_force_freeze()) as a last resort on corrupt tuples.- Developer options
ignore_checksum_failureandzero_damaged_pageschange whatPageIsVerified()failures do. Use them only on copies.
Parameters that shape this
Related topics
Physical layout on disk TOAST Free space map and visibility map MVCC: row versions HOT updates VACUUM B-tree indexes