Heap Storage
Pages, tuples, and relation files on disk
Every table you create with SQL Basics has to live
somewhere on disk. Postgres uses a heap — an unordered collection of
fixed-size pages, each holding a handful of rows — and understanding its
shape explains a lot of behavior that otherwise looks like magic, from why
SELECT * on a huge table is slow to why deleted rows don't shrink a table
immediately.
Pages
A table's data file is divided into 8KB pages (also called blocks). Every read and write happens a whole page at a time — Postgres never touches less than 8KB of disk, even to read a single row. A page holds:
- a header (checksums, free space pointers)
- an array of item pointers (line pointers), each pointing at a tuple later in the same page
- the tuples themselves, packed in from the end of the page backwards
Item pointers grow down from the header, tuples grow up from the end. Free space is whatever's left in the middle.
Tuples
A tuple is one physical row version. Every tuple carries a fixed header in front of your actual column data, including:
xmin— the transaction ID that created this tuple versionxmax— the transaction ID that deleted/replaced it (0 if still live)ctid— this tuple's own physical address,(page_number, item_index)
You can see ctid directly:
CREATE TABLE accounts (id serial PRIMARY KEY, balance numeric);
INSERT INTO accounts (balance) VALUES (100), (250), (75);
SELECT ctid, * FROM accounts;
-- ctid | id | balance
-- (0,1) | 1 | 100
-- (0,2) | 2 | 250
-- (0,3) | 3 | 75(0,1) means "page 0, item pointer 1." ctid changes every time a row is
updated — that's the first hint that UPDATE isn't an in-place edit, which
is the whole subject of the next lesson, MVCC.
Postgres does have a Heap-Only Tuple (HOT) optimization: if a new tuple version fits on the same page and no indexed column changed, it chains the old item pointer straight to the new tuple instead of touching every index. It reduces index bloat, but the row still gets a new physical location — HOT doesn't mean "updated in place."
Relation files on disk
Each table (and each index) is backed by one or more physical files under
the data directory, named after the table's relfilenode, not its name:
base/<database_oid>/<relfilenode>
-- find yours:
SELECT pg_relation_filepath('accounts');
-- base/16384/16391Files are capped at 1GB each — a table larger than that spills into
16391.1, 16391.2, and so on, all still logically one relation. This is
also why ALTER TABLE ... RENAME is instant: the file name never changes,
only the catalog entry does.
To see how many pages (and bytes) a table actually occupies:
SELECT pg_size_pretty(pg_relation_size('accounts'));
-- 8192 bytes (one page, for a table this small)pg_relation_size reports only the heap itself. A table's total footprint
— indexes, TOAST tables for large values, the free
space map — is usually several times larger. Use
pg_total_relation_size('accounts') for the real number.
Rows shrink out of a page only when VACUUM reclaims the space old tuple versions leave behind — a delete or update doesn't free anything immediately. Next: what happens when a single column's value is too big to fit in a page at all, in TOAST, FSM & Visibility Map.