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
ItemIdDataholding(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 columnctid. 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-changingALTER 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_initfor unlogged tables. - TOAST
- The Oversized-Attribute Storage Technique: a row wider than ~2 KB (¼ page) gets its wide
text/jsonb/byteavalues 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.
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
ctidciting(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.
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.
See Also
MVCC — How Postgres Implements Multi-Version Concurrency Control The next layer up: thexmin/xmax/t_ctid tuple header this page stores is exactly what MVCC reads to decide which row version each transaction can see.
Table Bloat & Autovacuum — Reclaiming What MVCC Leaves Behind
The janitor for this drawer: how VACUUM compacts pages, rewrites line pointers, and the Free Space / Visibility Map forks feed it.
Write-Ahead Log (WAL) — Crash Recovery & the Durability Guarantee
Where the torn-page hazard is actually defused: pd_lsn and full-page writes are the bridge from this page to crash recovery.
Buffer Pool / Page Cache — How a DB Manages Memory
These same 8 KB pages, cached in shared_buffers before they ever touch disk; the unit of I/O here is the unit of caching there.
Covering Indexes & Index-Only Scans
The heap fetch a covering index eliminates is the slot → byte-offset hop shown here; the Visibility Map fork is what lets it be skipped.
ACID Properties
Durability and atomicity rest on this layout: WAL-stamped pages and the atomic relfilenode swap behind fast, transactional TRUNCATE.
Isolation Levels & Read Anomalies
Isolation is enforced against the row versions physically stored in these pages via each transaction's snapshot.
Two-Phase Locking (2PL)
The other half of Postgres's concurrency story — strict-2PL locks paired with the MVCC versions that live in this heap.
Sources consulted
- PostgreSQL Documentation — Database Page Layout — fetched 2026-06-17
- PostgreSQL Documentation — Database File Layout — fetched 2026-06-17
- The Internals of PostgreSQL §1.3 — Internal layout of a heap table file — fetched 2026-06-17
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?