πŸ“š Study Notes / Home / DBMS / Session 3
Session 03 Β· Storage Engine β€” Heap Files

The Storage Engine β€” Heap Files & Free Space

Welcome back! In Session 2 we cracked open a single page and saw how rows are packed into it with a slotted layout. Today we zoom out one level: how does the database glue thousands of those pages together into a whole table, find a page with room to insert a new row, handle records that grow too big to fit, and clean up the mess left behind when you delete or update data? We assume you've studied none of this before. Every topic starts with a tiny "explain like I'm 5" story, then we go deeper with real diagrams, examples, and code. Take it slow β€” by the end you'll understand exactly where your rows physically live.

⏱ 20 min readπŸ“– 4 topics

1 Heap file organization


Explain like I'm 5

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

The big idea

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.

πŸ“‹
Header page
points to first page
β†’
πŸ“„
Page 1
rows + next ptr
β†’
πŸ“„
Page 2
rows + next ptr
β†’
πŸ“„
Page 3
next = null

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 entryPage #Free space
0page 0120 bytes
1page 10 bytes (full)
2page 23,900 bytes
3page 3512 bytes
What real systems do

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.

Worked example: scanning the users table

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.

Key takeaway

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.

Recap A heap file is an unordered pile of fixed-size pages making up one table. The engine finds pages via a linked list (simple, slow) or a page directory (an array of page β†’ free-space entries, fast). Because rows aren't sorted, an unindexed query becomes a full-table scan that reads every page β€” O(N).

2 Free space management


Explain like I'm 5

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.

PostgreSQL's FSM, concretely

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:

πŸ“₯
1. Need room
row is 200 bytes
β†’
πŸ—ΊοΈ
2. Ask the FSM
page with β‰₯ 200 free?
β†’
βœ…
3a. Found
insert into that page
β†’
βž•
3b. None
append a brand-new page
Worked example: inserting with an FSM

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:

TypeWhat it isPicture
Internal fragmentationA 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 fragmentationMany 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).

Watch out

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.

Recap A free space map tracks roughly how much room each page has, so an insert can find a home in O(log N) instead of scanning. PostgreSQL uses an approximate tree-shaped FSM. Over time pages suffer internal (holes within a page) and external (many half-empty pages) fragmentation; compaction slides rows together to reclaim contiguous space.

3 Variable-length records & overflow


Explain like I'm 5

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.

πŸ“„
Main page
row: id, name, ptr→
β†’
πŸ“¦
Overflow page 1
first chunk + next ptr
β†’
πŸ“¦
Overflow page 2
remaining chunk

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:

StepWhat TOAST doesWhy
1. CompressTry to compress big values in place first.Often shrinks the row enough to fit β€” cheapest fix.
2. Move out-of-lineIf 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. RepeatKeep compressing/moving columns until the main row fits in the page.Guarantees the row fits the 8 KB limit.
Worked example: a fat row goes on a diet

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 posts row now stores just id, 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.

Key takeaway

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

Recap Variable-length values are stored with offset+length in the slot. When a value grows past the page, it's pushed to overflow pages with a pointer in the row. PostgreSQL's TOAST compresses big columns, then moves them to a hidden TOAST table in ~2 KB chunks, keeping the main row under 8 KB β€” so queries that skip the big column stay cheap.

4 Deletes, updates & vacuum


Explain like I'm 5

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.

Why not delete immediately? (MVCC)

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.

OperationWhat it does
VACUUMMarks 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 FULLRewrites the whole table compactly into a new file, actually returning disk space to the OS β€” but it locks the table, so it's heavyweight.
AutovacuumA 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."
Why forwarding pointers exist

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

Worked example: an update that moves a row

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.

Watch out

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.

Recap Deletes are logical first (a tombstone marks the row dead) and physical later. MVCC keeps old versions around for concurrent readers, creating dead tuples that vacuum/compaction reclaim (VACUUM recycles space; VACUUM FULL shrinks the file). An update writes a new version and, if it no longer fits, moves the row, leaving a forwarding pointer so existing index addresses still resolve.

β˜… 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

Papers, docs & deep dives