Chapter 08 · Heap Storage, TOAST, HOT Updates, Bloat, and Page-Level Internals

Heap Pages, Line Pointers, Tuple Headers, Free Space, and Visibility Map

Connect ServiceHub SQL rows to PostgreSQL heap pages, relation forks, item identifiers, tuple headers, free-space and visibility metadata using supplied diagnostic extensions on disposable objects.

Intermediate → Advanced150–190 minutespageinspect + visibility-map labCurrent patched PostgreSQL 18.xpageinspect/pg_visibility/pg_freespacemap supplied extensions where indicatedLocal superuser required for raw-page inspectionNo paid or managed-service dependencyLast reviewed: August 2026

Learning outcomes

ServiceHub has an update-heavy work-order table. A developer notices that two rows with neighboring IDs are not necessarily adjacent on disk, a DBA sees a visibility-map change after one small update, and an application prototype tries to persist ctid as a row identifier. These are three versions of the same mistake: treating PostgreSQL's physical heap layout as if it were the relational model.

01

Define relation, fork, block/page, line pointer, heap tuple header, free-space map, visibility map, and CTID before using them diagnostically.

02

Use the supplied pageinspect extension on a disposable table to inspect page headers and item identifiers without reading relation files directly.

03

Connect pageinspect evidence to xmin/xmax/ctid concepts from Chapter 07 while distinguishing physical evidence from MVCC-visible SQL rows.

04

Observe free-space and visibility-map state with supported SQL interfaces and explain what VACUUM changes.

05

Prove why CTID/page positions are version-sensitive diagnostics and must not become application identity.

Privilege and safety boundary

The mandatory physical-inspection path assumes a free local PostgreSQL 18.x instance where you control a superuser. PostgreSQL documents pageinspect functions as superuser-only. Managed services may restrict them; the lesson always pairs them with ordinary SQL and size/statistics evidence so the mental model remains useful without raw-page access.

1. From a relation to forks, blocks, line pointers, and tuples

A PostgreSQL table is a relation. The relation's ordinary row storage lives in its main fork. PostgreSQL can maintain additional forks: a Free Space Map (FSM) recording approximate reusable space, a Visibility Map (VM) recording page-level all-visible/all-frozen information, and an initialization fork for unlogged relations. Not every fork exists for every relation at every moment.

The main fork is divided into fixed-size blocks, usually 8 KiB in standard builds. Do not hard-code 8192 in tooling: ask the server. A heap data page contains a page header, an array of item identifiers (often called line pointers), free space, heap tuple data, and no special index-style area at the end. A ctid identifies a tuple version as (block number, item/offset number).

Layer Mental model Stable application contract?
relation Database object with storage, metadata and access method. Object identity/name is SQL-visible; physical files are not an app API.
fork Main data, FSM, VM, or initialization storage stream. No; fork details are storage implementation.
page/block Fixed-size unit read/written by storage/buffer machinery. No; page layout is version/build sensitive.
line pointer Compact item identifier locating tuple data or redirect/unused state. No.
heap tuple One physical row version plus header metadata. Columns are SQL contract; tuple header is diagnostic.
ctid Current physical location of a tuple version. No—UPDATE/rewrite can change it.

2. Create the diagnostic tools and a disposable heap

Connect to servicehub_lab as your local PostgreSQL superuser only for extension installation/raw-page inspection. Create application objects as the ServiceHub owner. Do not grant pageinspect execution to the application role merely to make the lab convenient.

sql · superuser — verify and install supplied diagnostic extensions
SELECT version();SELECT name, default_version, installed_versionFROM pg_available_extensionsWHERE name IN ('pageinspect', 'pg_visibility', 'pg_freespacemap')ORDER BY name;CREATE EXTENSION IF NOT EXISTS pageinspect;CREATE EXTENSION IF NOT EXISTS pg_visibility;CREATE EXTENSION IF NOT EXISTS pg_freespacemap;SHOW block_size;
sql · owner — create a small deterministic heap
DROP TABLE IF EXISTS app.ch08_heap_probe;CREATE TABLE app.ch08_heap_probe (    work_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    work_code text NOT NULL UNIQUE,    status text NOT NULL,    note text NOT NULL,    touched_at timestamptz NOT NULL DEFAULT clock_timestamp()) WITH (fillfactor = 80);INSERT INTO app.ch08_heap_probe(work_code, status, note)SELECT 'WO-PAGE-' || lpad(g::text, 4, '0'),       CASE WHEN g % 3 = 0 THEN 'closed' ELSE 'open' END,       repeat('diagnostic-', 8)FROM generate_series(1, 200) AS g;ANALYZE app.ch08_heap_probe;

Identity and unique indexes exist, so the table has heap storage plus indexes. The selected fillfactor leaves room during initial page packing, but it is not a promise about exact page counts.

3. Observe fork sizes before touching raw bytes

sql · supported size interfaces
SELECT pg_size_pretty(pg_relation_size('app.ch08_heap_probe'::regclass, 'main')) AS main,       pg_size_pretty(pg_relation_size('app.ch08_heap_probe'::regclass, 'fsm'))  AS fsm,       pg_size_pretty(pg_relation_size('app.ch08_heap_probe'::regclass, 'vm'))   AS vm,       pg_size_pretty(pg_table_size('app.ch08_heap_probe'::regclass))           AS table_with_toast_forks,       pg_size_pretty(pg_indexes_size('app.ch08_heap_probe'::regclass))         AS indexes,       pg_size_pretty(pg_total_relation_size('app.ch08_heap_probe'::regclass))  AS total;

Exact byte counts are local observations. A zero-byte FSM/VM at one point does not mean PostgreSQL has no such concept; those forks are created and extended as needed. pg_table_size includes the table's auxiliary forks and TOAST relation when present, whereas pg_relation_size(...,'main') isolates one fork.

4. Free-space map evidence is approximate on purpose

The FSM is a search aid: PostgreSQL needs a fast way to find pages likely to have enough room for an incoming tuple version. The supplied pg_freespacemap extension exposes the amount recorded for each page. PostgreSQL documents that these values are rounded and are not kept perfectly current, so they are appropriate for understanding page-selection behavior, not byte-perfect accounting.

sql · superuser or pg_stat_scan_tables — inspect recorded page free space
SELECT blkno, availFROM pg_freespace('app.ch08_heap_probe'::regclass)ORDER BY blknoLIMIT 12;

Compare the FSM value with page_header() only as two different observations. The page header reflects one copied page at one moment; the FSM is approximate metadata optimized for locating candidate pages. A mismatch within this model is not corruption.

5. Read page 0 with pageinspect

get_raw_page() returns a time-consistent copy of one database page as bytea; page_header() decodes generic header fields. This does not bypass MVCC into a supported application API—it is a debugging tool.

sql · superuser — page header evidence
SELECT *FROM page_header(get_raw_page('app.ch08_heap_probe', 'main', 0));
text · expected shape, values vary
lsn      | checksum | flags | lower | upper | special | pagesize | version | prune_xid---------+----------+-------+-------+-------+---------+----------+---------+----------<dynamic> | <...>    | <...> | <...> | <...> | <...>   | <block>  | <...>   | <...>

lower advances with the line-pointer array; upper marks the beginning of tuple data/free-space boundary. The difference between these offsets is related to currently available page space, but treating raw offsets as a long-term monitoring API would be brittle.

6. Line pointers and tuple headers are not the same as SELECT rows

sql · superuser — compact heap_page_items view
SELECT lp, lp_off, lp_flags, lp_len,       t_xmin::text, t_xmax::text, t_ctid,       t_infomask, t_infomask2FROM heap_page_items(get_raw_page('app.ch08_heap_probe', 0))ORDER BY lp;

heap_page_items() reports every line pointer and tuple header found in the copied page, including tuple versions that might not be visible to an ordinary MVCC snapshot. Contrast that with a normal query:

sql · ordinary SQL — only visible logical rows
SELECT work_id, work_code, status, ctid, xmin::text, xmax::textFROM app.ch08_heap_probeORDER BY work_idLIMIT 8;

Chapter 07 already established that xmin/xmax require transaction-state interpretation. Page inspection adds physical location and flags, not a replacement visibility engine. If you need to know what the current SQL snapshot sees, ask SQL.

7. Visibility map: a page-level optimization fact, not row security

The visibility map tracks whether a heap page is known to contain only tuples visible to all current and future transactions, and whether all tuples are frozen. VACUUM maintains it. The all-visible bit lets an index-only scan sometimes avoid a heap visit because indexes do not themselves carry MVCC visibility information.

sql · before and after VACUUM
SELECT *FROM pg_visibility_map_summary('app.ch08_heap_probe'::regclass);VACUUM (ANALYZE) app.ch08_heap_probe;SELECT *FROM pg_visibility_map_summary('app.ch08_heap_probe'::regclass);

The exact counts depend on the table's pages and concurrent activity. After a vacuum on this quiet disposable table, more pages are commonly marked all-visible. Modify one row and inspect again:

sql · invalidate page-level all-visible knowledge with a write
UPDATE app.ch08_heap_probeSET note = note || ' changed'WHERE work_id = 1;SELECT *FROM pg_visibility_map_summary('app.ch08_heap_probe'::regclass);

The bit is conservative metadata: a write to a page can clear its all-visible status. It is not an authorization flag and does not mean every query can read every row.

8. Wrong approach: CTID as a primary key

sql · observe CTID movement across a normal update
SELECT work_id, ctidFROM app.ch08_heap_probeWHERE work_id = 2;UPDATE app.ch08_heap_probeSET note = note || ' v2'WHERE work_id = 2RETURNING work_id, ctid;

The returned ctid identifies the new tuple version. It can differ even for a logically unchanged key. HOT chains, VACUUM pruning, VACUUM FULL, CLUSTER, table rewrites, and other operations make physical identity unsuitable for durable references.

Repair

Use a declared primary/unique key for business identity. CTID is acceptable in carefully bounded maintenance/diagnostic techniques only when the operation explicitly tolerates it changing and rechecks row identity.

9. Reproducible verification and cleanup

sql · verification checklist
SELECT relname, relpages, reltuplesFROM pg_classWHERE oid = 'app.ch08_heap_probe'::regclass;SELECT count(*) AS visible_rows,       min(work_id) AS min_id,       max(work_id) AS max_idFROM app.ch08_heap_probe;SELECT *FROM pg_visibility_map_summary('app.ch08_heap_probe'::regclass);

relpages/reltuples are catalog statistics, not an exact live row inventory. Keep the distinction between estimates, logical query results, and raw-page evidence explicit.

sql · cleanup teaching table
DROP TABLE IF EXISTS app.ch08_heap_probe;-- Leave pageinspect/pg_visibility installed if this database is reused by later labs.-- If the whole database is disposable, dropping that database cleans them too.

Check your understanding

  1. What is the difference between the main fork and the visibility-map fork?
  2. Does heap_page_items return only tuples visible to your current SQL snapshot?
  3. Why can an index-only scan care about the visibility map?
  4. Why is CTID unsuitable as ServiceHub business identity?
  5. Does a zero-byte FSM/VM observation prove that PostgreSQL never uses that fork?
Review the answers

The main fork stores heap pages; the VM stores page-level all-visible/all-frozen bits. pageinspect exposes physical page contents independent of ordinary MVCC filtering. Index-only scans can skip heap visibility checks for pages known all-visible. CTID is a physical tuple-version address that can change. Auxiliary forks can be absent/empty until needed, so one size sample is not a permanent capability statement.

10. Production judgment and bridge

Use page internals to explain a symptom, validate a hypothesis, or learn the engine—not as an application schema. Prefer supported statistics and size functions for routine monitoring, and restrict raw-page tools tightly. Lesson 2 now follows the other consequence of fixed-size heap pages: what PostgreSQL does when one attribute is too large to remain comfortably inline.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.