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
Every 8 KB page opens with the same fixed 24-byte header
(PageHeaderData). Most of it is bookkeeping, but four fields run the whole
show — the two that mark where free space begins and ends, pd_lower and
pd_upper:
page header · PageHeaderData (24 bytes), then the rest of the 8 KB page
Now pick a row size below and insert rows. Watch the page fill from both
directions at once: a line-pointer array grows downward from just under
the header (pushing pd_lower down 4 bytes per row), while the tuples (your
rows) are written upward from the bottom (pulling pd_upper up by each
tuple's size). The gap between them is the free space.
insert rows → tuples fill from the bottom · click a tuple to delete it (its space stays — that's bloat, until VACUUM)
// why it matters · why fill from both ends?
It's not an accident — it's what makes a page cheap to manage. The line pointers
must be a dense, fixed-size array (an index entry says "line pointer #3", so
slot 3 has to be at a predictable byte offset), so they grow forward from a known
start. Tuples are variable length, so they pack backward from the end. That
leaves free space as a single contiguous gap in the middle — adding a row is
just "does it fit between pd_lower and pd_upper?", and reclaiming space is
"slide the tuples together." Two ends, one gap, no free-list bookkeeping inside
the page.
Because a page is a fixed 8 KB, a table's file only ever grows in whole 8 KB steps. Insert enough rows to spill onto new pages and watch the file size jump one block at a time:
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:
Each line pointer is a 4-byte slot (ItemIdData) — just 32 bits, packed
into three fields:
lp_off(15 bits) — the byte offset within the page where the tuple actually sits.lp_len(15 bits) — the tuple's length in bytes.lp_flags(2 bits) — the slot's state, which is where the cleverness lives.
Those 2 flag bits give a slot one of four states:
LP_UNUSED— empty and reusable.LP_NORMAL— points at a live tuple (the usual case).LP_REDIRECT— points at another line pointer, not a tuple. This is how HOT update chains work (next section): an index still points at slot 3, but slot 3 forwards to the current version's slot.LP_DEAD— the tuple is gone; the slot's space can be reclaimed.
// why it matters · the indirection is the whole point
Because an index (and everyone else) holds the line-pointer number, not a byte
offset, Postgres can move a tuple within its page during cleanup — slide it
to defragment, or repoint a slot — and just rewrite lp_off. The ctid the
index stored never changes. A B-tree leaf doesn't store rows, it stores these
ctids: an index lookup ends with a (block, offset), then one heap-page read
follows the line pointer to the tuple. That's the bridge to the next lesson.
The tuple header — and a sneak peek at MVCC
Before its actual column data, every tuple carries a 23-byte header
(HeapTupleHeaderData). It's small, but every field earns its place:
tuple header · HeapTupleHeaderData (23 bytes), then optional null bitmap, then your columns
The two fields that matter most decide who can see the row: t_xmin (the
transaction that created this version) and t_xmax (the transaction that
deleted or superseded it, or 0 if it's still live). They're exposed as system
columns — select them like any other:
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 rest of the header does real work too:
t_ctidnormally points at the tuple itself, but after anUPDATEit becomes a forwarding pointer to the newer version — the update chain you'll see next.infomask/infomask2are packed status bits. They cache expensive facts so Postgres doesn't recompute them: isxminknown-committed? does this row have any NULLs? was it HOT-updated?t_hoffsays where the header ends and column data begins, because between them sits an optional null bitmap — one bit per column. If a column is NULL, its bit is 0 and it occupies zero data bytes (the value simply isn't stored). That's why a table full of mostly-NULL wide columns can be far smaller than it looks.
An UPDATE doesn't modify a row — it writes a new one
This surprises people. Update one row and watch its ctid change:
Postgres didn't edit the row in place. It wrote a brand-new tuple for the
new version (with a fresh xmin), stamped the old version's t_xmax with your
transaction id, and set the old version's t_ctid to point forward at the new
one. The old bytes are still sitting on the page — now a dead version. Every
UPDATE is really an insert-plus-tombstone; that's the heart of MVCC, and the
reason the next section (bloat) exists.
// note · HOT: the optimization that avoids index churn
Naively, a new tuple means a new entry in every index on the table — expensive.
So Postgres has HOT (Heap-Only Tuple) updates: if the update changes no
indexed column and the new version fits on the same page, it skips the
indexes entirely. The old line pointer becomes an LP_REDIRECT that forwards
to the new tuple, and the new tuple is marked heap-only (no index points at it
directly). Existing index entries still find the row by landing on the old slot
and following the redirect. It's a big deal for update-heavy tables — and the
reason to keep frequently-updated columns out of your indexes.
Deleting (and updating) leaves bloat
DELETE doesn't erase anything either — it just stamps the tuple's t_xmax so
no future snapshot can see it. The bytes stay exactly where they were. Prove it:
build a table, delete 90 % of it, and watch the file size not budge:
Same file size, a tenth of the rows. Those 900 dead tuples still occupy their
pages. The same thing happens on the write side: every UPDATE leaves its old
version behind, so an update-heavy table accumulates dead versions fast.
// gotcha · dead space isn't free space
The gap between “logically gone” and “physically reclaimed”
is bloat. A dead tuple's line pointer stays LP_NORMAL until VACUUM (the
lesson after MVCC) marks it LP_DEAD and then reusable — putting the freed room
in the free space map so future inserts land there. VACUUM only rarely
returns space to the OS, so the file plateaus rather than shrinks. (Postgres can
also do lightweight HOT pruning on the fly, reclaiming dead HOT versions
within a page during ordinary reads.) An update- or delete-heavy table with no
effective autovacuum is the classic cause of mysterious, ever-growing tables.
Column order is not free
You can declare the same columns in any order and get the same data — but not the same row size. The reason is alignment, and it's worth understanding byte-for-byte, because on a wide, high-row-count table a careless order silently wastes gigabytes of disk and cache.
Why alignment exists
A CPU reads memory fastest when a value sits on a boundary that's a multiple of
its size: an 8-byte bigint read from an address divisible by 8 is a single
aligned fetch; read it from a ragged offset and the hardware has to do extra
work (and on some platforms it faults outright). So Postgres stores every value
on its natural boundary. Each type carries an alignment requirement — it's
the typalign column in pg_type:
bool,"char"→ 1 byte (fits at any offset)smallint(int2) → 2int,float4→ 4bigint,timestamptz,float8→ 8
Column values are laid out in declaration order, right after the 24-byte tuple header. When the next value's type needs a boundary the current offset doesn't sit on, Postgres inserts padding — dead filler bytes that hold nothing — to reach it. That padding is the entire cost of a bad column order.
Watch the padding form
Below are six columns in a careless, interleaved order. Follow the byte strip: a
1-byte bool immediately followed by an 8-byte bigint forces 7 padding
bytes to reach the next 8-byte boundary (the cyan gridlines). Reorder the
columns with the arrows — or hit pack optimally — and watch the padding, and
the row size, collapse to nothing.
column data
24 B
padding wasted
16 B
row on disk
64 B
+24B header
wasted / 1M rows
15.3 MB
Putting the widest-alignment columns first packs every value tight against the previous one, so no padding is ever needed — the same six columns, the same data, in a 40 % smaller row.
It's real — measure it
pg_column_size returns a row's exact on-disk byte count. The interleaved order
is 64 bytes/row; the packed order is 48 — a 16-byte difference that is
pure padding:
Sixteen bytes per row sounds trivial. Multiply it out — fill both tables with 50,000 identical rows and compare the actual files on disk:
The badly-ordered table is roughly a third larger — for byte-identical data.
// why it matters · why a few bytes per row matters so much
It isn't really about disk being cheap. Smaller rows mean more rows per 8 KB page, so a scan reads fewer pages — and, the big one, more of the table fits in the buffer cache (the memory lessons ahead). A table that's 30 % smaller needs ~30 % fewer page reads and pushes ~30 % less useful data out of RAM. Padding is wasted disk and wasted cache and wasted I/O, on every query that touches the table, forever.
// note · the rule — and why Postgres won't do it for you
Declare fixed-width columns widest-alignment first —
bigint/timestamptz/float8 (8) → int/float4 (4) → smallint (2) →
bool (1) — then variable-length columns (text, numeric, jsonb) last.
Reordering costs nothing: it's the same data with no downside. Postgres stores
exactly the order you declare (it never silently reorders) so that the on-disk
format stays stable and predictable — which means getting it right is your job.
Why 8 KB, and why fixed?
The block size is a compile-time constant (BLCKSZ, 8 KB by default
everywhere). Two separate choices are baked in here — the size, and the fact
that it's fixed.
Why fixed at all? Uniform blocks make everything downstream simple: the buffer cache is a tidy array of equal-sized slots (any page fits any slot), one heap fetch is one predictable I/O, and the WAL never has to describe variable-length blocks. The cost is that a single row can't exceed one page — which is exactly the problem TOAST (next) solves.
Why 8 KB specifically? It's a tuned middle between two opposing pressures:
- Bigger pages (16/32 KB) amortize I/O nicely for sequential scans and make indexes shallower — but they punish random access. To read one 100-byte row you must still fetch the whole block, so a 32 KB page reads 4× the data for the same row, and every page write to the WAL (full-page images) gets bigger.
- Smaller pages (4/2 KB) cut that read amplification, but each page pays the fixed 24-byte header and per-page overhead more often, and indexes grow more levels (more pages to hold the same keys), so lookups touch more blocks.
8 KB has been the sweet spot for OLTP-style mixed workloads for decades. And it quietly sets other limits: the ~2 KB TOAST threshold below is "about a quarter of a page."
What about huge values? TOAST
A row can't exceed a page — so what happens when you store a 10 KB blob of text? TOAST (The Oversized-Attribute Storage Technique). When a row would blow past the ~2 KB TOAST threshold (about a quarter of a page), Postgres works oversized columns in two steps: first compress the value; if it's still too big, move it out-of-line into a hidden companion toast table, leaving only an 18-byte pointer on the main page.
Watch both halves happen. Store one very compressible value and one incompressible one, then ask how many bytes each actually takes on disk:
Same logical length, two orders of magnitude apart on disk — the first compressed roughly 68×, the second couldn't compress at all. And the big values don't live in the table's own file; they're in its toast table:
// note · the four storage strategies
Each column has a storage mode you can set with ALTER TABLE … ALTER COLUMN … SET STORAGE: PLAIN (never TOAST — only for fixed-width types), MAIN
(compress inline, avoid out-of-line if possible), EXTENDED (compress and
allow out-of-line — the default for text/jsonb/bytea), and EXTERNAL
(out-of-line but no compression — faster substring reads on big values).
Out-of-line values are sliced into ~2 KB chunks and reassembled on read.
It's completely transparent: you SELECT body and get your string back, never
knowing it was compressed and scattered across a toast table.
Big tables: forks and segments
One table isn't one file — it's a small family of them, called forks, all
sharing a numeric base name (the relfilenode):
- The main fork — the 8 KB pages you've been inspecting all lesson.
- The free space map (
_fsm) — tracks how much room each page has, so anINSERTcan jump straight to a page with space instead of scanning. - The visibility map (
_vm) — one bit per page marking it all-visible (every tuple visible to everyone). It's what letsVACUUMskip clean pages and lets index-only scans answer without touching the heap.
You can see where the main file lives on disk:
The _fsm and _vm forks start empty and are built as the table grows and gets
vacuumed — on a tiny table they're 0.
// note · why 1 GB segments
The main fork itself is split into 1 GB segments on disk —
relfilenode, then relfilenode.1, .2, and so on. Historically some
filesystems couldn't handle very large files; splitting also keeps individual
files manageable to copy and mmap. It's invisible from SQL — a 10 GB table
is ten segment files that Postgres addresses as one continuous sequence of block
numbers.
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, so the file grows in 8 KB steps.
- 02Each page opens with a 24-byte header; pd_lower/pd_upper mark the free-space gap. Line pointers grow down from the front, variable-length tuples pack up from the back, so free space is one contiguous middle gap.
- 03A ctid is (block, offset); the offset names a 4-byte line pointer (lp_off/lp_len/lp_flags). Its four flag states (UNUSED/NORMAL/REDIRECT/DEAD) let tuples move and HOT chains redirect — the ctid the index holds never changes.
- 04Every tuple has a 23-byte header (t_xmin, t_xmax, t_ctid, infomask, t_hoff…). xmin/xmax drive MVCC visibility; a null bitmap makes NULLs cost zero data bytes.
- 05UPDATE writes a brand-new tuple and forwards the old one's t_ctid to it; HOT updates skip index writes when no indexed column changes and it fits on the page. DELETE only stamps xmax — the file doesn't shrink; the dead space is bloat for VACUUM.
- 06Column declaration order causes alignment padding (types align to 1/2/4/8 bytes). Declaring widest-alignment first can shrink rows ~30–40%, which ripples into fewer page reads and more cache hits.
- 07Values past ~2 KB are TOASTed: compressed, and if still large, stored out-of-line in a toast table (a 10 KB compressible string can land in ~150 bytes). 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