← databases book ⊞ All topics

How Data Is Stored on Disk — Heap Files, Pages & Slots

A PostgreSQL table is just a heap file on disk ("heap" = an unordered pile of rows, not a binary heap) cut into fixed 8 KB pages, and inside each page rows are found indirectly through a small array of line pointers (a.k.a. slots). That one layer of indirection — an index or ctid points at a slot number, and the slot points at the tuple's current byte offset — is the keystone that makes in-page compaction, HOT updates, MVCC, and VACUUM possible.

Key Components

Heap file
The on-disk file that holds a table's rows as an unordered pile ("heap"), created at base/<database-oid>/<relfilenode>. It is capped at 1 GB per file; larger relations spill into numbered segments (67890, 67890.1, …).
Page (8 KB block)
The fixed-size unit of heap I/O. Postgres reads and writes a whole 8 KB block, never a fraction. A page has four regions: a 24-byte header, a line-pointer array growing down, free space ("the hole"), and tuple data growing up from the end.
Line pointer (slot / item identifier)
A 4-byte ItemIdData holding (byte offset, length, flags). Line pointers form an array numbered from 1; that index is the offset number (the slot). It is the indirection layer decoupling a row's logical address (slot) from its physical address (bytes).
TID / ctid
A row's physical address, (block number, offset number), exposed as the system column ctid. Indexes point at the slot, never at a raw byte position, so the address stays valid even after tuples shuffle within a page.
relfilenode
The number that names the heap file. It often equals the table OID but diverges after a rewrite (TRUNCATE, CLUSTER, VACUUM FULL, REINDEX, type-changing ALTER TABLE), which build a new file and atomically swap the table's pointer to it.
Forks (FSM & VM)
One table is several files sharing the base name: the main fork (rows), _fsm — the Free Space Map (which pages have room for new tuples), _vm — the Visibility Map (which pages are all-visible), and _init for unlogged tables.
TOAST
The Oversized-Attribute Storage Technique: a row wider than ~2 KB (¼ page) gets its wide text/jsonb/bytea values compressed and/or pushed out-of-line into a companion TOAST table, fetched only when that column is read.

Concrete Example

A row's physical address is a TID (block number, offset number), surfaced as the system column ctid. You can read it directly, and you can ask Postgres where a table's file lives on disk:

-- ctid = (block, slot), e.g. (0,3): block 0, slot #3
SELECT ctid, * FROM accounts WHERE id = 7;

-- Where does the heap file live?  base/<db-oid>/<relfilenode>
SELECT pg_relation_filepath('accounts');

Inside one 8 KB page, four regions share the block — and two of them grow toward each other from opposite ends until the free space between them is exhausted:

 ┌───────────────────────────────┐ byte 0
 │ PageHeaderData      (24 bytes) │  fixed header
 ├───────────────────────────────┤
 │ line pointers (4 B each) ──▶   │  grow DOWN, end marked by pd_lower
 ├───────────────────────────────┤
 │      free space ("the hole")   │
 ├───────────────────────────────┤
 │   ◀── tuples (heap rows)       │  grow UP, start marked by pd_upper
 ├───────────────────────────────┤
 │ special space (empty for heap; │  B-tree pages put sibling links here
 └───────────────────────────────┘ byte 8191

The header fields that matter: pd_lsn (LSN of the last WAL record to touch this page — the link used in recovery), pd_lower (end of the line-pointer array = start of free space), and pd_upper (start of tuple data = end of free space). Free space is simply pd_upper − pd_lower; when it hits zero the page is full and the Free Space Map picks another page. A line pointer holds (byte offset, length, flags), and reading a row is a two-hop lookup: go to block N → look up slot #K → follow its offset to the tuple.

Visual Model

Picture a heap file as a filing cabinet and each page as one fixed-size drawer. Taped to the inside of the drawer face is an index-card array — the line pointers; card slot #3 says "folder #3 is 4 inches from the back." Folders (tuples) pack from the back forward; cards are added from the front; they grow toward each other until the drawer is full. You cite (drawer, card slot) — the TID — never inches-from-the-back, because folders shuffle forward when old ones are cleaned out, yet the slot number never changes, so cross-references elsewhere in the office (the indexes) stay valid.

Step through how a row is actually located, and watch what VACUUM can and cannot move.

Step 1 of N
Index entry / ctid (block 0, slot 3) One 8 KB page (block 0) PageHeaderData (24 B) · pd_lower / pd_upper line pointers ▼ slot #1 → offset 8140 slot #2 → offset 8020 slot #3 → offset 7900 free space ("the hole") = pd_upper − pd_lower tuples ▲ tuple (row 2) @ off 8020 tuple (row 3) @ byte off 7900 xmin · xmax · t_ctid · data… tuple (row 3) slid up @ off 8010 byte offset changed — slot #3 did NOT VACUUM: line pointer #3 rewritten to the new offset — TID (0,3) still valid

Loading…

Deeper — Edge Cases & Gotchas

Why the slot indirection is worth it

Reading a row is one level of indirection — block N → slot #K → byte offset — and it exists because the slot number is stable while the tuple's byte offset is not:

  • Compaction. VACUUM slides surviving tuples together to coalesce the hole. Tuples move; each line pointer is rewritten to the new offset — but the offset numbers stay the same, so every index entry and ctid citing (N, K) is still valid.
  • No reverse map needed. If indexes stored the byte offset directly, every in-page compaction or prune would have to find and rewrite every index entry across all indexes on the table (write amplification), and you'd need a nonexistent reverse map from tuple → indexes.
  • Line-pointer states. Slots carry LP_NORMAL / LP_DEAD / LP_UNUSED / LP_REDIRECT (redirect → another slot) — the machinery behind HOT chains and pruning under MVCC.

The tuple header — where row versions live

Each row is a heap tuple with a ~23-byte header: t_xmin (inserting txn), t_xmax (deleting/locking txn), t_ctid (TID of this tuple or of the next, newer version — the forward pointer chaining an UPDATE to its successor), t_infomask/t_infomask2 (flags plus commit-status hint bits), and t_hoff (offset to user data). Because xmin/xmax and the ctid forward pointer live physically in the page, row versions sit in the heap and DELETE/UPDATE cannot free space immediately.

Pages vs sectors — the torn-page hazard

Each layer has its own smallest atom, and they don't match: relation → 1 GB segment file → 8 KB Postgres page (the DB's atom) → filesystem block (~4 KB) → device sector (512 B or 4 KB, the hardware's atom). One 8 KB page spans 16 sectors (512 B) or 2 (4 KB), but the disk only guarantees that one sector is written all-or-nothing. Its defense — full-page writes — copies the entire 8 KB page into the WAL on its first modification after a checkpoint, so recovery can stamp a known-good image back down before replaying later changes.

Anti-pattern: treating ctid as a stable, persistent row identifier — caching it in an application, a foreign key, or an external system and expecting it to still address the same row later.
-- DON'T: stash a ctid and reuse it as a durable row id
SELECT ctid FROM accounts WHERE id = 7;   -- (0,3)  ← looks like an address
-- ...later, after an UPDATE or a VACUUM FULL / CLUSTER...
SELECT * FROM accounts WHERE ctid = '(0,3)';   -- may be a DIFFERENT row, or gone

Why it breaks: two distinct situations change a row's TID. An UPDATE writes a new version elsewhere (a new TID) and marks the old one expired — the row didn't "move," a successor was created. And a rewrite (VACUUM FULL / CLUSTER) physically relocates live tuples across pages, reassigning their TIDs. (Ordinary lazy VACUUM does not change a live tuple's TID — it only compacts within a page and rewrites the line pointer.) Use the table's real primary key for durable identity; ctid is valid only within a single snapshot.

Test Yourself

Line pointers grow down and tuples grow up. Why is the slot indirection worth it — what breaks or gets expensive if indexes pointed at a tuple's byte offset instead of its slot number?

Postgres does heap I/O one 8 KB page at a time, though a device sector is much smaller. Name the correctness hazard on a crash mid-write, and the mechanism that defends against it.

A table's file is named by relfilenode, not its OID. Name an operation that changes relfilenode, and explain why that makes TRUNCATE behave the way it does — what does it get to skip?