Storage Layout
// the problem
A Postgres table isn't a magic grid of rows — it's an ordinary file on disk, and that file is just a stack of identical 8 KB pages. Every row you've ever stored lives inside one of these blocks. By the end of this lesson you'll know exactly what one looks like, byte by byte — and you'll prove every claim against a real Postgres running in your browser.
Run this first — it spins up an actual Postgres (WASM) and creates a table we'll inspect throughout the lesson. The first run downloads the engine, so give it a second.
A table is stored in a file, and Postgres reads and writes that file one page at a time. You can even see where it lives and how big it is:
Inside one page
Pick a row size below and insert rows. Watch the page fill from both directions at once: a small line-pointer array grows downward from just under the header, while the tuples (your rows) are written upward from the bottom. The empty middle is free space — they grow toward each other until they meet, and the next row spills onto a fresh page.
insert rows → tuples fill from the bottom · click a tuple to delete it (its space stays — that's bloat, until VACUUM)
That two-ended design is real. The header records two offsets, pd_lower and
pd_upper; the gap between them is the free space. Insert a row and
pd_lower moves down by 4 bytes (a new line pointer) while pd_upper moves up
by the tuple's size.
The line pointer, and what a ctid really is
Every row's address is its ctid — a pair (block, offset). The block
is which 8 KB page; the offset is which line pointer (1-based), not a
byte position. Here are the real ctids of our rows:
A line pointer is a tiny 4-byte slot (ItemIdData) that stores where the
tuple actually sits on the page, plus a few status bits. That indirection is the
whole trick: Postgres can move a tuple within its page during cleanup and only
update the pointer — the ctid other parts of the system hold never changes.
// why it matters · this is why indexes store ctids
A B-tree index leaf doesn't store rows — it stores ctids pointing back
into these heap pages. That's the bridge to the next lesson: an index lookup
ends with a ctid, then one heap-page read fetches the row.
The tuple header — and a sneak peek at MVCC
Each tuple carries a 23-byte header before its column data. Two of its fields
decide who can see the row: xmin (the transaction that created this
version) and xmax (the transaction that deleted/superseded it, or 0). They
're exposed as system columns:
Every row you inserted shares one xmin (the transaction that ran the setup) and
has xmax = 0 (still live). Hold onto this — the MVCC lesson is entirely a
story about xmin/xmax.
The header also holds t_ctid (a forwarding pointer to a newer version),
t_infomask status bits, and a null bitmap so NULL columns cost no data
bytes.
An UPDATE doesn't modify a row — it writes a new one
This surprises people. Update one row and watch its ctid change:
The old version is still on the page, now marked dead (its xmax set to your
transaction). Postgres wrote a brand-new tuple for the updated row and pointed
the old one at it. That's MVCC in miniature — and it's why the next
point matters.
Deleting (and updating) leaves bloat
Click a tuple in the explorer above to delete it, and watch the free-space
number: it doesn't move. DELETE only marks a tuple dead — its bytes
stay put. Same for the old version left behind by every UPDATE.
// gotcha · dead space isn't free space
The gap between “logically gone” and “physically reclaimed” is bloat. It's cleaned up later by VACUUM, which marks the dead space reusable (and only rarely shrinks the file). An update-heavy table with no effective autovacuum is the classic cause of mysterious table growth.
Column order is not free
Postgres stores columns in the order you declare them, and it aligns many
types to their size (an int8 starts on an 8-byte boundary). Declaring columns
in a poor order leaves padding holes in every single row. The effect is real
and measurable — same six columns, two orderings, ten thousand rows:
// why it matters · order columns big → small
A rough rule that avoids most padding: declare fixed-width columns from
largest to smallest (bigint/timestamptz → int → smallint →
bool), then variable-length columns (text, numeric) last. On wide,
high-row-count tables this routinely saves 10–20% of disk and cache.
Why 8 KB, and why fixed?
The block size is a compile-time constant (BLCKSZ, 8 KB by default
everywhere). Fixing it means one heap fetch is one predictable I/O, the buffer
cache is a tidy array of equal-sized slots, and the WAL and on-disk format never
have to reason about variable-length blocks. The trade-off: a single row can
never exceed one page.
What about huge values? TOAST
When a value would blow past roughly a quarter of a page (~2 KB), Postgres TOASTs it (The Oversized-Attribute Storage Technique): it first tries to compress the value, and if it's still too big, stores it out-of-line in a hidden companion toast table, leaving only a small pointer on the main page. Your row always fits in 8 KB.
Big tables: forks and segments
One table is actually several files (“forks”): the main fork of
8 KB pages you've been inspecting, a free space map (_fsm) that
tracks where there's room to insert, and a visibility map (_vm) that
lets VACUUM and index-only scans skip all-visible pages. And because some
filesystems dislike enormous files, the main fork is split into 1 GB
segments on disk.
Your turn
Prove you can read the storage yourself (the accounts table is from the first
cell — run it first).
Show every row's physical address (its ctid) alongside its owner, ordered by ctid.
Prove that a bigint takes more on-disk bytes than an int, using pg_column_size.
// what you now understand
- 01A table is a file of fixed 8 KB pages; Postgres always does I/O a whole page at a time.
- 02A page fills from both ends: the line-pointer array grows down (pd_lower), tuples grow up (pd_upper), free space shrinks between them.
- 03A ctid is (block, offset); the offset names a 4-byte line pointer, so a tuple can move within its page without its ctid changing — which is why indexes store ctids.
- 04Every tuple has a 23-byte header; xmin/xmax there drive visibility (MVCC).
- 05UPDATE writes a new tuple version and DELETE only marks dead — the leftover space is bloat, reclaimed later by VACUUM.
- 06Column declaration order causes alignment padding; ordering largest→smallest can save 10–20% on disk.
- 07Oversized values are compressed and/or moved out-of-line via TOAST; big tables also carry _fsm/_vm forks and split into 1 GB segments.
// self-test
You DELETE 1,000,000 rows from a table. What happens to the file's size on disk, immediately?
// self-test
Why can a tuple's physical position on a page change without breaking the indexes that point to it?
// go deeper
- Database Page Layout (official docs) — PageHeaderData, ItemIdData, HeapTupleHeaderData, field by field
- TOAST — thresholds, compression, out-of-line storage
- bufpage.h — the actual page header struct in the source
- htup_details.h — the tuple header: xmin, xmax, infomask, null bitmap