postgres://internals

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.

sql · live postgresrun me first — creates the demo table
⌘/ctrl + enter

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:

sql · live postgreseditable — run it
⌘/ctrl + enter

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.

fig.01 · heap pageblock size 8192 B
row size
pages
1
rows
0
last page fill
0%
last ctid
page 08.0 KB free
header 24B
line ptrs
free 8.0 KB

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:

sql · live postgreseditable — run it
⌘/ctrl + enter

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:

sql · live postgreseditable — run it
⌘/ctrl + enter

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:

sql · live postgresrun, then notice id=1's ctid moved
⌘/ctrl + enter

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:

sql · live postgrescompare the two file sizes
⌘/ctrl + enter

// why it matters · order columns big → small

A rough rule that avoids most padding: declare fixed-width columns from largest to smallest (bigint/timestamptzintsmallintbool), 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.

sql · live postgreseditable — run it
⌘/ctrl + enter

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).

// challengewrite it yourself

Show every row's physical address (its ctid) alongside its owner, ordered by ctid.

⌘/ctrl + enter
// challengewrite it yourself

Prove that a bigint takes more on-disk bytes than an int, using pg_column_size.

⌘/ctrl + enter

// 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

nextB-tree Indexes