CHAPTER DEEPREAD · DATABASE INTERNALS · CH 3
Database Internals · Ch 3 · Alex Petrov · 2019
The photos on your phone, your chat history, your company's order book — all of it ends up as a file on a disk. The previous chapter explained why databases use a tree of signposts to find things. This chapter goes one floor down: what does one node of that tree actually look like, as a pile of bytes on disk? It sounds like tedious clean-up work. In fact, whether any of those elegant algorithms can run on real hardware is decided right here.
Think of a disk as a ring binder. It won't let you "insert a sentence on line 37". You can only take out a whole page, rewrite it, and put it back. But the things a database has to store come in wildly different sizes — an order is 80 bytes, a product description is 3,000. How do you pack ragged content neatly into fixed-size pages, and still be able to add and remove things at any time?
In memory, someone else does the dirty work for you: ask the system for space and you get some; hand it back when you're done; remember where a thing lives by keeping its house number. On disk all three conveniences vanish. The house number is worthless once the thing moves to another page, nobody fills in the hole a deletion leaves behind, and the page size is fixed. And if you take the easy route — one fixed-size cubbyhole per record — the cubbyhole has to be sized for the longest record, so short records waste most of their space.
The answer databases reached is about as clever as laying out a menu: write the body from the back of the page forward, keep the table of contents at the front, leave the gap in the middle.
Each page also carries a tally number: computed and stored when the page is written, recomputed when it is read back. If the two disagree, you know the page quietly rotted on disk. The front of the file holds a passphrase and a version number, so a future program can tell at a glance which set of rules to read it by.
Nearly every database file you can open looks like this: PostgreSQL uses 8 KB pages, MySQL 16 KB, SQLite 4 KB, and inside each one sits a directory at the front and content at the back. The cost is honest too: deleting does not hand your disk space back right away — the holes come back only at some later tidy-up, and that tidy-up rewrites the whole page.
Disks only deal in whole pages, but records come in ragged sizes. The database's answer is a page with a directory at the front: content lies wherever it fell, the directory keeps the order; deletions go on a "vacant lots" list and get tidied in a batch; and every page carries a tally number so it can't rot in silence.
Want the real byte layouts, the slotted-page diagrams and what each engine actually chose? → Switch to the deep read
You'd think "file formats" is the fussy clean-up work of deciding how bytes are arranged. It is in fact the landing layer where the elegant B-Tree of the previous chapter meets real hardware. The chapter has a single thesis: the three things an allocator, a pointer and a garbage collector do for you in memory — holding variable-size objects, reclaiming freed space, and referring to an object stably — all have to be rebuilt by hand on disk. What gets built is everything in this chapter: fixed-size pages, the in-page slotted page directory, cells that hold the data, an availability list for the holes, plus a version so the file knows what it is and a checksum so you find out when it rots.
n lives at n × page size: one multiplication.This is Chapter 3 of Part I, "Storage Engines". It follows Chapter 2, which argued why the B-Tree is the default answer on disk but only ever drew the shape of the tree, never what a node looks like in bytes. It sets up Chapter 4, "Implementing B-Trees" — splits, merges, concurrency, page management — all of which stand on the page layout defined here. In the real world: the page you dump with PostgreSQL's pageinspect extension, an InnoDB index page, a b-tree page inside a SQLite file. Those are this chapter, made of actual bytes.
The root of it: the memory programming model and the disk programming model break apart in four places — and every data structure we're used to was written for the memory one. Granularity: memory is byte-addressable, disks move whole pages, and the cost of one random I/O is roughly the same whether you wanted 1 byte or 8 KiB (tens to hundreds of microseconds on NVMe, 5–10 ms on spinning disks). So how you arrange bytes within a page directly decides how much useful data one I/O buys you. References: a memory pointer is a virtual address and is worthless after a restart; on disk a reference must become a durable coordinate, "page ID + offset within the page". Allocation and reclamation: no malloc/free, no garbage collector — the hole a deletion leaves is yours to record, reuse and tidy. Trustworthiness: in memory, what you read is what you wrote; on disk, not necessarily — bit rot, misdirected writes and torn writes all hand you garbage that looks perfectly well-formed.
What happens if you don't solve it? Concretely: give every record a fixed-size slot and the slot must be sized for the longest record — say 512 bytes. An 80-byte order then wastes 84% of its slot, and an 8 KiB page holds just 16 of them. Go the other way, packing variable-size records tightly in key order, and inserting in the middle means memmove-ing kilobytes, while the holes left by deletions never come back. This chapter is the search for a layout that dodges both kinds of stupid at once.
Table 1 · Four things that are free in memory and must be built by hand on disk
| Capability | In memory (who does it for you) | On disk (this chapter's answer) |
|---|---|---|
| Hold variable-size objects | The allocator hands you a block of any size | Fixed pages + cells with length prefixes; overflow pages when it won't fit |
| Reclaim freed space | free() or a GC, compaction included | Availability list + in-page compaction, on your own schedule |
| Refer to an object stably | A pointer; the address holds as long as the object doesn't move | Page ID + slot number: the physical offset may move, the slot number doesn't |
| Trust the bytes you read | Broadly safe (ECC memory underneath) | Per-page checksums: computed on write, verified on read |
The bottom layer is binary encoding: how one value becomes a string of bytes. Fixed-size numbers are written at a fixed width, but the format must pin down endianness. On-disk formats lean toward big-endian (most significant byte first, also called network byte order) for a very practical reason: for big-endian unsigned integers, comparing byte by byte gives the same answer as comparing the numbers — keys can go straight through memcmp without being deserialized first. SQLite's format spells it out: every multi-byte field is big-endian.
Variable-size data (strings, blobs) has two classic options: a C-style terminator (read until \0 — which means scanning byte by byte, and the content may never contain a \0), or a length prefix (write the length, then the bytes). Databases universally take the second: you know the size before you read, so you can grab it in one go — or skip it entirely. Bit packing covers fields with only a handful of possible values: PostgreSQL's tuple header carries a null bitmap that spends 1 bit per column to record "is this column NULL".
One level up is the file as a whole: a header followed by a long run of equally sized pages. The header holds the global facts — magic number, format version, page size. The first 16 bytes of a SQLite file are always SQLite format 3\0. Why insist on fixed-size pages? Because fixed size means page n lives at n × page size — one multiplication to address it, with no page-directory indirection anywhere — and it lets slots in the page cache be swapped and reused interchangeably. It's the least glamorous constraint in the design and the one that saves the most work.
Now the protagonist. A page layout has to satisfy three requirements at the same time:
The slotted page catches all three at once. The page is cut into three parts: a header, a slot directory growing forward from the front (an array of fixed-size offsets, usually 2 bytes each), and a cell area growing backward from the tail, with free space caught in between. The header keeps two offsets marking the boundaries of that gap; when the two pointers meet, the page is full.
Each requirement then lands somewhere concrete. Insert writes the cell at the tail end of the gap and appends an offset to the slot directory — a variable-size record takes only the bytes it needs, plus a 2-byte slot and a small cell header, so overhead comes down to single-digit bytes (①). Delete marks the slot invalid and hands the cell's bytes to the availability list for reuse (②). And the slot number is the stable public identity: when a record moves inside its page you only update the offset in its slot, and every outside reference holding "page ID + slot number" stays valid (③).
PostgreSQL is the textbook specimen of this structure: 8 KiB pages by default, a 24-byte page header, then 4-byte line pointers (ItemIdData — the slots) growing forward while tuples are packed backward from the tail, with the header's two 2-byte offsets pd_lower and pd_upper bracketing the free space between them.
This is the prettiest idea in the chapter, and the easiest to read past. A B-Tree needs the keys inside a node to be ordered so it can binary-search within the page. The naive approach keeps the cells themselves in key order — and then inserting a key in the middle means shifting the kilobytes that follow it.
What actually happens: the slot directory is ordered, the cells lie in physical disarray. Inserting a middle key appends the cell to the tail of the gap (nothing else moves) and memmoves a short run of offsets in the directory. Do the arithmetic: an 8 KiB page holding 100 records shifts at worst 100 × 2 = 200 bytes of offsets rather than kilobytes of record bodies — more than an order of magnitude cheaper, and it's a small contiguous array that the CPU cache loves. Lookups binary-search that same little sorted array, following each offset to read the key it points at.
Worth contrasting: InnoDB takes a variant. Its records are chained by "next record" pointers into a singly linked list in key order, and the in-page page directory is only a sparse index — each slot owns roughly 4–8 records. A lookup binary-searches the sparse directory to a neighbourhood, then walks a few links. Smaller directory, at the price of a few extra pointer hops. One problem, two defensible answers — exactly the kind of trade-off this chapter keeps demonstrating.
A B-Tree has two kinds of node, so a page has two kinds of cell. A separator (key) cell holds only "key + child page ID" and does navigation in internal nodes; a key-value cell holds "key + data" and appears only in leaves. Why split them? Because an internal node needs signposts, not payload — the same page then fits many more separator keys, which means higher fan-out and a shallower tree, exactly the conclusion Chapter 2 landed on. The cell's small header records its own length, the key length, and either the value length (leaf) or the child page ID (internal), with the variable-size fields following.
What if a value is too large for a page at all? Two roads. Overflow pages: the cell keeps a prefix plus a pointer, and the rest is chained across overflow pages — SQLite's approach. Or move it aside: PostgreSQL's TOAST kicks in once a row exceeds roughly 2 kB (TOAST_TUPLE_THRESHOLD), compressing it and relocating large fields in chunks into a side table, leaving a pointer in the row. The goal is to keep at least 4 rows per 8 KiB page — a tree with one row per page has hopeless fan-out.
Deleting a record usually moves nothing: its slot is marked invalid and the bytes its cell occupied go onto an availability list (freelist) — each hole's header records its own size and the offset of the next hole, chaining them together. The next insert walks the chain looking for a hole big enough: first-fit takes the first one that fits — fast, but it often leaves a sliver behind; best-fit scans the whole chain for the snuggest one — thriftier, but it costs a traversal and the leftovers are smaller and less usable. SQLite calls these holes freeblocks and separately counts "fragmented free bytes": the crumbs too small to be worth chaining at all.
Once fragmentation crosses a threshold — plenty of free space in total, but no single run large enough for the next cell — the page triggers defragmentation (compaction): surviving cells are repacked tightly against the tail and the offsets in the slot directory are updated to match. The cost is rewriting the whole page; the payoff is scattered space gathered back into one run. And requirement ③ pays off precisely here: compaction moves every cell, yet the slot numbers don't change, so every reference from outside the page survives untouched.
Two unglamorous, life-saving fields. Version: formats change, so a file has to state what it is, or a newer binary reading an older file is a disaster. Cassandra puts the version straight into the SSTable's filename — in nb-1-big-Data.db, nb is the format version and big the format type, so you know which reader to use without opening the file.
Checksum: compute a short digest when the page is written, store it in the page, recompute on read; a mismatch means the page is damaged. It guards against bit rot, misdirected writes, and the half-written page a power loss leaves behind. Three things to keep straight: it protects against accidents, not adversaries (it is not a cryptographic hash — whoever can change the data can change the checksum); it only finds the problem when you happen to read that page (which is why serious systems also scrub proactively); and it says nothing about corruption in memory — data mangled in the page cache goes to disk with a perfectly "correct" new checksum.
Table 2 · Choosing a page size (real defaults in brackets)
| Page size | Upside | Cost | Who picks it |
|---|---|---|---|
| 4 KiB | Aligns with common filesystem / SSD blocks; little waste for small records; finer lock granularity, less contention | Taller tree, more I/Os per lookup; header + slot directory are a larger share of overhead | SQLite (default 4096, tunable 512–65536) |
| 8 KiB | The usual compromise between fan-out and waste; a ~2 kB row limit still fits most OLTP rows | Wide rows trip TOAST / overflow, costing an extra hop | PostgreSQL (BLCKSZ, a compile-time constant) |
| 16 KiB | High fan-out, shallow tree; better value on scans and bulk writes | Read amplification: 16 KiB moved for a 100-byte record; coarser page locks | MySQL / InnoDB (settable 4–64 KiB) |
| ≥ 64 KiB | Best efficiency for large sequential I/O; suits scan-heavy workloads | Read and write amplification on random point lookups get ugly | Analytical / columnar formats and some LSM block sizes |
Table 3 · Three ways to lay variable-size records out in a page
| Fixed-size slots | Slotted page | Immutable append (SSTable) | |
|---|---|---|---|
| Layout | One equally sized cubbyhole per record | Directory keeps order; cells grow from the tail | Sorted once, written once, read-only thereafter |
| Space efficiency | Poor: slots sized for the longest record — an 80-byte row in 512 bytes wastes 84% | Good: actual length + ~2-byte slot + small cell header | Best: packed tight, and compressible in blocks |
| Middle insert | Shifts every following slot | memmoves offsets only (hundreds of bytes) | Not supported — it goes into a new file |
| Reclaiming deletes | Slot marked empty, immediately reusable | Chained on the availability list; compacted when fragmented | Not reclaimed in place — dropped during file compaction |
| Fits | Genuinely fixed-size data (evenly sized time-series samples) | Update-in-place B-Tree engines: PostgreSQL, InnoDB, SQLite | Write-heavy LSM engines that tolerate read amplification: Cassandra, RocksDB |
Table 4 · Managing free space
| Strategy | How it works | Cost | When to use it |
|---|---|---|---|
| First-fit | Walk the chain, take the first hole that fits | Slivers left behind are often unusable; fragmentation builds fast | Frequent inserts of similarly sized records; you want speed |
| Best-fit | Scan the whole chain, take the snuggest hole | A traversal per insert; leftovers are even smaller | Widely varying record sizes, space under pressure |
| Compact when fragmented | Past a threshold, repack all surviving cells tightly | A full page rewrite plus its WAL record; a latency spike | The fallback in essentially every B-Tree engine |
| Don't reclaim at all | Files are immutable; space is freed when they are merged away | Until then, several versions of the same key sit on disk | The LSM side: reclamation moves from page level to file level |
This chapter cashes out in three very concrete places. You can read a symptom: "I deleted half the data — why hasn't the table file shrunk by a single byte?" Because deletion only marks and chains; reuse happens inside the page and the file never shrinks. In PostgreSQL you wait for VACUUM to return dead tuples' space to the page, and only VACUUM FULL, which rewrites the table, actually shrinks the file. You know where the modelling cliff is: cross roughly 2 kB of row width and PostgreSQL starts moving fields into the TOAST table, so a row that was one I/O becomes two — the drop-off when you "stuff big JSON into the main table" isn't mysterious, the page just can't hold it. And you can answer cleanly in a review: "isn't an in-page B-Tree insert an O(n) shift?" — what shifts are the offsets in the slot directory, not the record bodies.
ItemIdData) allocated forward from the front while tuples are packed backward from the tail, with the 2-byte pd_lower / pd_upper offsets bracketing the unallocated space between — a textbook slotted page, in the field. PostgreSQL docs, "Database Page Layout" ↗SQLite format 3\0; every multi-byte header field is big-endian; a b-tree page is a header plus a 2-byte big-endian cell offset array plus a cell content area growing back from the tail; holes are freeblocks chained together, each storing "next freeblock offset + own size", and a fragmented page can be reorganized so that all cells are packed tightly at the end — availability list and in-page compaction, verbatim. SQLite, "Database File Format" ↗initdb (turning them off now needs an explicit --no-data-checksums) — a moved default is the clearest statement that the community thinks the overhead is worth paying today. PostgreSQL 18 release notes ↗<version>-<generation>-<format>-<component>.db (e.g. nb-1-big-Data.db) — a minimal implementation of this chapter's versioning argument: know the reader before you open the file. Apache Cassandra blog, "New SSTable Identifiers", 2022 ↗VACUUM, and only VACUUM FULL shrinks the file).mmap-ing a C struct is the fastest thing to write and the fastest to run — and it welds your format to this machine's endianness, alignment rules and even compiler version, so another platform can't read it. Petrov's position is unambiguous: define every byte explicitly, which is exactly why mainstream formats pin down their own byte order.① In one line: on disk there is no allocator, no pointer, no garbage collector — this chapter rebuilds all three inside a file. It is the layer where the B-Tree comes down from paper onto hardware.
② Four breaks: granularity (whole pages only; one random I/O is tens of microseconds on NVMe, 5–10 ms on spinning disks), references (pointer → page ID + offset), reclamation (you manage the holes), trustworthiness (bytes rot quietly).
③ Encoding: pin down endianness for fixed-size numbers (big-endian lets you compare keys byte by byte), use length prefixes rather than terminators for variable-size data, and bit-pack flags and null markers.
④ File skeleton: magic number + version + a run of fixed-size pages — fixed size is what makes "where is page n" a single multiplication.
⑤ The slotted page is the heart: header + slot directory growing forward + cells growing backward, catching all three requirements — variable-size records, reclaimable space, and references not welded to physical positions.
⑥ The prettiest move: decoupling logical from physical order — the directory is sorted, the cells are not; a middle insert shifts a few hundred bytes of offsets instead of kilobytes of records. InnoDB answers the same question differently, with an ordered linked list plus a sparse directory.
⑦ Two kinds of cell: internal nodes carry "separator key + child page ID" to buy fan-out; leaves carry key and value, and oversized values go to overflow pages (SQLite) or a side table (PostgreSQL's TOAST, threshold ~2 kB, to keep at least 4 rows per page).
⑧ Space management: delete = mark + chain on the availability list (first-fit is fast, best-fit is thrifty); compact the page once no single run fits — the cost is a full page rewrite, the payoff is contiguous space, and stable slot numbers keep every outside reference intact.
⑨ Two life-saving fields: a version so the file states what it is (Cassandra puts it in the filename), and a checksum to catch silent corruption — but it guards against accidents, not adversaries, and only when you read.
⑩ The selection rule: page size (SQLite 4 KiB / PostgreSQL 8 KiB / InnoDB 16 KiB) and page layout both pick a point among wasted space, tree height, read amplification and lock granularity. There is no universal optimum.