1 Heap file organization
Imagine a big toy box. When you get a new toy, you don't carefully sort it β you just toss it wherever there's space. The toys aren't in any order; they're just all in the box. If your mum asks "is your red car in there?", the only way to be sure is to dump everything out and look at every toy. A heap file is the database's toy box: it stores your rows wherever there's room, in no particular order.
Recall from Session 2 that the database stores data in fixed-size pages
(usually 4 KB or 8 KB), and that within a page, rows live in a slotted layout. But a table
like users has way more rows than fit in one page. So the database needs a
way to organize many pages into one logical table. The simplest and most common way is a
heap file.
A heap file is an unordered collection of pages where rows (also called tuples) are stored with no sorting and no relationship between their physical position and their values. New rows simply go wherever there's free space. The name has nothing to do with the "heap" data structure from algorithms class β here it just means "an unsorted pile."
A table is not one giant blob β it's a collection of fixed-size pages. The storage engine's job is to track which pages belong to the table, where each one is, and which ones still have room. A heap file is the bookkeeping that turns a pile of pages into a table.
Two ways to organize the pages
If a table is just "a set of pages," the engine still needs to find them on disk. There are two classic designs, and real systems generally use the second.
Option A β Linked list of pages
The database keeps a special header page with two pointers: one to the first page that has free space, and one to the first full page. Every data page then stores a pointer to the next page, forming a chain β like a treasure hunt where each clue points to the next.
This works, but it's clumsy. To find a page with free space you might have to walk the whole chain, following pointer after pointer β slow when a table has millions of pages.
Option B β Page directory
A smarter design keeps a page directory: special directory pages that hold an array of entries, one per data page. Each entry records the page's location (its page number) and how much free space it currently has. To find room for a new row, the engine just scans this compact directory instead of every data page.
| Directory entry | Page # | Free space |
|---|---|---|
| 0 | page 0 | 120 bytes |
| 1 | page 1 | 0 bytes (full) |
| 2 | page 2 | 3,900 bytes |
| 3 | page 3 | 512 bytes |
PostgreSQL stores each table as one or more heap files on disk (literally files named by an object ID) and tracks free space in a separate structure we'll meet in Topic 2. SQL Server, Oracle, and others use page-directory-style structures. The linked-list design is mostly a teaching baseline β the directory wins in practice because it makes "find a page with room" fast.
How a full-table scan works
Because a heap is unordered, the only way to answer a query that can't use an index β say
SELECT * FROM users WHERE age > 30 with no index on age
β is a full-table scan (also called a sequential scan):
read every page, look at every row, and keep the ones that match. It's the
toy-box dump-and-look from our story.
Say users has 4 pages. Here's the pseudocode the engine effectively runs for
a sequential scan:
# Full-table scan over a heap file matches = [] for page_id in heap.all_pages(): # walk every page in the heap page = buffer_pool.fetch(page_id) # load it into memory (Session 4!) for slot in page.slots(): # walk every slot in the page if slot.is_live(): # skip deleted/empty slots row = page.read(slot) if row.age > 30: # apply the WHERE filter matches.append(row) return matches
Cost is roughly O(N) in the number of pages β read all of them, every time. For a 1-million-row table that's brutal. This is exactly the pain that indexes (a later session) exist to solve: they let you jump to the right pages instead of reading them all.
A heap stores rows in insertion-driven, unordered fashion β great for fast inserts, but any lookup with no index degenerates into reading the entire table. The page directory makes writes fast (quickly find a page with room); it does nothing for reads that filter on a column β those still scan everything.
2 Free space management
Imagine a parking garage with hundreds of floors. You're driving in with a car and you want a spot fast β you don't want to drive up every floor checking. So at the entrance there's a board: "Floor 1: full. Floor 2: 3 spots. Floor 3: 1 spot." You glance at the board, pick a floor with room, and go straight there. A database keeps a board exactly like this so it can instantly find a page with enough room for your new row.
Every time you INSERT a row, the engine must answer one question: which
page has enough free space to hold it? Scanning every page to find out would be far too slow, so
databases keep a dedicated structure that tracks free space.
The free space map
A free space map (FSM) is a compact index that records, for each page, roughly
how many bytes are free. It's the "parking board." The page directory from Topic 1 is one way to implement
it; PostgreSQL keeps a separate small file (the _fsm fork) shaped as a tree so it
can find a page with enough room in O(log N) instead of scanning.
Postgres stores free space as a number from 0β255 per page (a rounded "how many bytes free" value, so the map stays tiny). The FSM is organized as a tree: upper nodes summarize the max free space below them, so a search can prune whole branches. It's approximate on purpose β being slightly stale is fine, because the engine re-checks the real page before writing.
Finding a page to insert into
Here's the decision the storage engine makes on every insert:
Suppose page size is 8,192 bytes and the FSM currently says:
# Free space map snapshot (bytes free per page) page 0 -> 0 # full page 1 -> 40 # almost full page 2 -> 512 page 3 -> 7000 # INSERT a 200-byte row: target = fsm.find_page(200) # first page with >= 200 free # skips page 0 (0) and page 1 (40), picks page 2 (512) page2.insert(row) # now ~312 bytes free fsm.update(page=2, free=312) # keep the map current
The engine never touched pages 0, 1, or 3 β it went straight to page 2. That's the whole point of the map: turn "find room" from an O(N) scan into a quick lookup.
Fragmentation β why pages get messy
Over time, inserts, deletes, and updates leave pages with awkward gaps. There are two flavors of wasted space:
| Type | What it is | Picture |
|---|---|---|
| Internal fragmentation | A page has free bytes scattered between live rows, but no single hole big enough for a new row. | [row][gap][row][gap] β 300 free total, but in 50-byte pieces. |
| External fragmentation | Many pages are each only partly full, so the table spans more pages than its data really needs. | Lots of half-empty pages = wasted disk & slower scans. |
Recall the slotted-page layout from Session 2: rows are added from one end and the slot array grows from the other. When a row is deleted, its bytes become a hole. The engine can compact a page β slide the remaining rows together to merge the holes into one contiguous free region β so a new row can fit. The slot array means it can do this without breaking any external pointers (more on that in Topic 4).
The FSM is approximate and can be stale. If it claims a page has 200 bytes free but the space is fragmented into tiny holes, the insert into that page fails and the engine has to try another. Slightly-wrong-but-fast beats perfectly-accurate-but-slow here β correctness is rechecked at write time, so a stale map only ever costs a retry, never a corrupted row.
3 Variable-length records & overflow
Imagine your school cubby is exactly big enough for your normal stuff. One day you bring a giant poster that won't fit. You don't get a bigger cubby β instead you put a sticky note in your cubby that says "my poster is in the big storage room, shelf 7," and the poster lives in the storage room. Your cubby stays neat, and you can still find the poster from the note. Databases do the same with values too big to fit on a page.
Many columns are variable-length: a VARCHAR,
TEXT, JSON, or BLOB can be a few
bytes or a few megabytes. From Session 2 you know a slotted page handles variable-length rows by storing,
in each slot, an offset and length pointing at where the row's bytes live in the page.
That's fine β until a value is too big to fit on a single page at all.
Records that grow and shrink
When you UPDATE a row and a variable-length column gets longer,
the row may no longer fit in its current spot. Two things can happen:
- It still fits in the page: the engine compacts the page to make a big-enough hole and rewrites the row there, updating its slot's offset.
- It no longer fits in the page: the engine moves the row to a different page entirely and leaves a forwarding pointer behind β covered in detail in Topic 4.
When a value shrinks, the freed bytes become a hole, recovered later by compaction.
The hard case: values bigger than a page
If a single column value is larger than a page (e.g. a 2 MB document in a 8 KB page), it physically cannot live inline. Databases use overflow pages: the big value is stored on one or more extra pages, and the main row keeps only a small pointer to them.
TOAST β PostgreSQL's take
PostgreSQL calls this mechanism TOAST (The Oversized-Attribute Storage Technique). Because a Postgres row must fit within a single 8 KB page, when a row gets too big the engine springs into action on the large variable-length columns, in this order:
| Step | What TOAST does | Why |
|---|---|---|
| 1. Compress | Try to compress big values in place first. | Often shrinks the row enough to fit β cheapest fix. |
| 2. Move out-of-line | If still too big, store the value in a separate hidden TOAST table, split into ~2 KB chunks. | The main row keeps only a small pointer. |
| 3. Repeat | Keep compressing/moving columns until the main row fits in the page. | Guarantees the row fits the 8 KB limit. |
You insert a blog post:
CREATE TABLE posts ( id INT, title VARCHAR(200), body TEXT -- could be 1 MB! ); INSERT INTO posts VALUES (1, 'Hello', '... 1,000,000 chars of text ...');
The 1 MB body can't fit in an 8 KB page. So PostgreSQL:
- Compresses
body(say, down to 300 KB). - Still too big β splits it into ~2 KB chunks and writes them to the post table's TOAST table.
- The main
postsrow now stores justid,title, and a tiny TOAST pointer (a few bytes) to the chunks.
The beautiful side effect: a query that only reads id and
title never touches the TOAST table at all β so it stays fast even though
body is huge. The big value is only fetched (and decompressed) when you
actually SELECT body.
Keep rows small and uniform on the main page; push oversized values out-of-line and leave a pointer. This keeps scans fast (more rows per page, less to read) and lets the page layout stay simple. Overflow pages and TOAST are the same idea: "big things live elsewhere; the row holds a sticky note."
4 Deletes, updates & vacuum
When you "delete" a contact on your phone, the phone often doesn't truly erase it right away β it just crosses it out so it won't show up, and actually wipes it later when it tidies up. Crossing out is instant; truly erasing and re-packing everything is slow, so the phone batches that for later. Databases delete rows the same lazy way β and then run a "tidy-up" job to reclaim the space.
Logical vs physical deletion
There are two very different meanings of "delete":
- Logical deletion β mark the row as dead but leave its bytes in place. Fast, and safe for concurrent readers who might still need the old version.
- Physical deletion β actually reclaim the bytes so the space can be reused. Slower, done later.
The marker left by a logical delete is called a tombstone: a flag (often a
single bit, or in MVCC systems a transaction id) that says "this row is no longer live; skip it." Look back
at the scan pseudocode in Topic 1 β the slot.is_live() check is exactly what skips
tombstoned rows.
In MVCC (Multi-Version Concurrency Control β used by PostgreSQL, MySQL/InnoDB,
Oracle), a DELETE doesn't remove the row; it marks the version as expired as of
your transaction. Older transactions that started before your delete can still see the old
version β so the row's bytes must linger until no transaction needs them. We'll explore MVCC properly in
a later session; for now just know it's why deletes are lazy.
Why we need vacuum / compaction
All those tombstoned, dead rows are dead tuples β bytes that are logically gone but still physically occupying pages. Left alone they cause bloat: the table grows on disk and scans get slower because they wade through dead rows. The cleanup job is called vacuum (PostgreSQL's name) or, more generally, compaction.
| Operation | What it does |
|---|---|
VACUUM | Marks dead-tuple space as reusable for future inserts and updates the free space map. The file usually doesn't shrink β it's recycled in place. |
VACUUM FULL | Rewrites the whole table compactly into a new file, actually returning disk space to the OS β but it locks the table, so it's heavyweight. |
| Autovacuum | A background daemon that runs VACUUM automatically when a table accumulates enough dead tuples, so you rarely run it by hand. |
How an update can move a tuple
An UPDATE is conceptually a delete-plus-insert. In MVCC systems it literally
writes a new version of the row and tombstones the old one. Two cases matter:
- New version fits on the same page: great β it's written nearby, old version becomes a dead tuple for vacuum to clean.
- New version doesn't fit (e.g. a column grew): the new version goes to a different page, and the old slot is replaced with a forwarding pointer (PostgreSQL: a redirect in the page's item array) that says "the current row is over there."
Indexes (a later session) point at rows by a physical address β in PostgreSQL a TID = (page number, slot number). If an update physically moved the row, every index pointing at it would break. The forwarding pointer lets the row move while the old address still works: a lookup lands on the old slot, sees the redirect, and hops to the new location. (Postgres's HOT β Heap-Only Tuple β optimization avoids even touching indexes when no indexed column changed.)
Start with a tiny products heap. Row for product 42 lives at TID (page 5,
slot 2):
# Page 5, before: slot 0 -> (id=10, name="Pen") slot 1 -> (id=20, name="Mug") slot 2 -> (id=42, name="Hat") # <- we will update this UPDATE products SET name = 'Wide-brim summer straw hat, limited edition' WHERE id = 42;
The new, longer name no longer fits in page 5's free space. The engine writes the new version to page 9, slot 0, and turns the old slot into a redirect:
# Page 5, after: slot 0 -> (id=10, name="Pen") slot 1 -> (id=20, name="Mug") slot 2 -> REDIRECT to (page 9, slot 0) # forwarding pointer # Page 9, after: slot 0 -> (id=42, name="Wide-brim summer straw hat, limited edition")
An index entry pointing at (page 5, slot 2) still works: the reader lands there, sees the redirect, and
follows it to (page 9, slot 0). Later, VACUUM can clean up the chain. If too many
rows forward this way, lookups slow down (an extra hop each), which is one more reason vacuum and
occasional VACUUM FULL matter.
A workload with heavy updates/deletes and no vacuuming leads to runaway bloat: the table balloons with dead tuples and forwarding chains, queries crawl, and disk fills up. This is a real production incident, not a theoretical worry β autovacuum tuning exists precisely to prevent it.
β Putting it all together
You just learned how a database turns a pile of pages into a living, breathing table. Here's the one-paragraph story that connects all four topics:
A table is a heap file β an unordered collection of fixed-size pages, located via a page directory (or a linked list). Because it's unordered, any unindexed query becomes a full-table scan that reads every page. To insert quickly, the engine consults a free space map to find a page with room in O(log N), fighting the fragmentation that builds up over time. Values too big for a page are pushed to overflow pages β PostgreSQL's TOAST compresses them and stores them out-of-line in chunks, keeping the main row small. Deletes are logical (a tombstone) before they're physical, leaving dead tuples that vacuum/compaction reclaim; and an update that no longer fits moves the row and leaves a forwarding pointer so index addresses still resolve. Next session we stop assuming pages are "just there" and look at the buffer pool β how pages move between disk and memory.
Quick self-check
Why does an unindexed WHERE query on a heap require a full-table scan?
Because a heap stores rows in no particular order, there's no shortcut to the matching rows β the engine must read every page and check every live row. That's O(N) in the number of pages.
What problem does a free space map solve, and what's its rough complexity?
It lets an insert quickly find a page with enough room without scanning every page. PostgreSQL's tree-shaped FSM finds a suitable page in about O(log N). It's approximate, so a stale entry only ever costs a retry.
A row has a 1 MB TEXT column but pages are 8 KB. What happens?
It can't fit inline, so it goes out-of-line. PostgreSQL's TOAST compresses the value, then (if still too big) splits it into ~2 KB chunks stored in a hidden TOAST table, leaving only a small pointer in the main row. Queries that don't read that column never touch the TOAST table.
What's a tombstone, and why don't databases delete rows immediately?
A tombstone is a marker that flags a row as logically deleted without erasing its bytes. Under MVCC, older transactions may still need the old version, so the bytes linger as a dead tuple until no transaction needs them β then vacuum reclaims the space.
Why does a database leave a forwarding pointer when an update moves a row to another page?
Indexes reference rows by physical address (e.g. PostgreSQL's TID = page + slot). If the row physically moved, those addresses would break. The forwarding pointer keeps the old address valid: a lookup lands on the old slot, sees the redirect, and hops to the new location.
What is bloat, and what fixes it?
Bloat is accumulated dead tuples (and forwarding chains) that swell a table and slow scans. VACUUM marks that space reusable and updates the FSM; VACUUM FULL rewrites the table to actually shrink it on disk. Autovacuum runs VACUUM automatically.
π References & Further Reading
Class material
- π Original course notes / handout (source sheet) β open the shared class material for this session.
- DBMS Session 3 β Storage Engine 2 β the class handout that accompanies this session.
Papers, docs & deep dives
- CMU 15-445 β Database Systems β Andy Pavlo's renowned course; the storage / heap-file / page-layout lectures are the gold-standard intro to everything in this session.
- PostgreSQL docs β TOAST β the official, authoritative description of how PostgreSQL compresses and stores oversized attributes out-of-line (Topic 3).
- PostgreSQL docs β Routine Vacuuming β explains dead tuples, bloat, VACUUM vs VACUUM FULL, and autovacuum (Topic 4).
- PostgreSQL docs β Database Page Layout β how a heap page, item pointers, and TIDs/redirects actually look on disk; ties Topic 1 and Topic 4 together.
- PostgreSQL docs β Free Space Map β the precise design of the FSM fork behind Topic 2.
- Alex Petrov β "Database Internals" β an excellent book whose storage-engine chapters cover heap files, slotted pages, and overflow in depth.