1 Why databases think in pages
Imagine you keep all your toys in a huge warehouse far away. Every time you want a toy, a truck has to drive there and back โ that takes ages. So instead of fetching one toy at a time, the truck always brings back a whole box of toys at once. Even if you only wanted one toy, you get the box, because driving the truck is the slow part. A database does the same thing: the disk is far away and slow, so it always reads and writes a whole box of data at a time, never a single item.
A database's job is to store more data than fits in memory, on a disk, and still answer questions quickly. To do that it has to understand one harsh fact of hardware: disks are slow, and they don't let you read one byte at a time. This single fact shapes the entire design of a storage engine.
The hardware reality: blocks, not bytes
Storage hardware does not hand you individual bytes. It works in fixed-size chunks:
- A spinning hard disk drive (HDD) is organised into sectors (classically 512 bytes, now often 4 KB). To read anything, the disk must physically spin the platter and move a read head to the right track โ a slow, mechanical seek.
- A solid-state drive (SSD) has no moving parts, but it still reads and writes in fixed units called pages (typically 4 KB) and erases in larger blocks. You cannot rewrite a single byte in place.
So at the hardware level, the smallest amount you can usefully transfer is a block, not a byte. The numbers behind this are dramatic:
| Operation | Rough latency | Relative speed |
|---|---|---|
| Read 1 byte from RAM | ~100 nanoseconds | 1ร (baseline) |
| Read a block from an SSD | ~100 microseconds | ~1,000ร slower |
| Read a block from an HDD (with a seek) | ~10 milliseconds | ~100,000ร slower |
Reading from a disk is thousands to hundreds of thousands of times slower than reading from memory. And crucially, most of that cost is the setup โ the seek and rotation on an HDD, or the controller round-trip on an SSD. Once the head is in place, grabbing the next few kilobytes is almost free. This is the key insight:
Because the expensive part of disk I/O is finding the data, not transferring it, the database amortises that cost by always moving a fixed-size chunk โ a page โ at a time. The page is the database's fundamental unit of I/O.
What is a page?
A page (sometimes called a block) is a fixed-size, contiguous region of the database file โ the unit the storage engine reads from and writes to disk as a single operation. Common sizes:
- PostgreSQL: 8 KB pages (default).
- MySQL / InnoDB: 16 KB pages (default).
- SQLite: 4 KB pages (default, configurable).
- SQL Server / Oracle: 8 KB pages.
The page size is chosen to be a multiple of the operating system's and hardware's block size so that one logical page maps cleanly onto whole physical blocks โ no wasted half-reads.
The words page and block are used almost interchangeably, but pedantically: a block is the hardware/OS unit, and a page is the database's logical unit (often the same size, often a multiple of the block). When CMU lectures say "page," they mean the database's chunk.
Why row-at-a-time disk access is too slow
Suppose a table row (a tuple) is 128 bytes. If the database fetched one row per disk read, every single row would cost a full seek โ ~10 ms on an HDD. Reading a million rows would take ~10,000 seconds (almost 3 hours) just in seek time. By contrast, packing those same rows into 8 KB pages (about 60 rows per page) means ~16,000 page reads instead of a million โ roughly 60ร fewer disk trips.
Table: 1,000,000 rows, each 128 bytes. Page size: 8 KB (8,192 bytes).
- Rows per page โ 8,192 รท 128 โ 64 rows (ignoring header overhead).
- Pages needed โ 1,000,000 รท 64 โ 15,625 page reads.
- One-row-per-read would need 1,000,000 reads.
- That's a 64ร reduction in the number of slow disk operations โ and a full-table scan reads those 15,625 pages sequentially, which disks love.
The database never thinks in individual rows when talking to disk โ it thinks in pages. Memory and CPU are cheap; disk seeks are not. Every layer above (records, indexes, the buffer pool) is built on top of the page abstraction. We'll see in Session 3 how pages are collected into heap files, and in later sessions how a buffer pool caches hot pages in RAM.
2 The anatomy of a page
Think of a page like a moving box. On the outside of the box you write a label: what's inside, how full it is, which box comes next. Inside the box you pack the actual stuff. A database page is exactly that: a little label area at the top (the header) that describes the box, and the rest of the box where the real data lives.
A page is just a fixed-size array of bytes โ say 8,192 of them. The storage engine imposes structure on those raw bytes. Almost every page is split into two regions: a small page header and a larger data region.
The page header
The page header is a small block of metadata at the start of the page (often 20โ100 bytes) that describes the page itself. It typically holds:
| Field | What it stores | Why it's needed |
|---|---|---|
| Page ID / number | This page's own identity within the file. | So pages can reference each other. |
| Page type | Data page? Index page? Free-space map? | The engine treats different page types differently. |
| Free-space pointers | Where the used data ends / free area begins. | To know where the next record can go. |
| Record / slot count | How many records this page currently holds. | To iterate over the page's contents. |
| Checksum / LSN | A checksum for corruption detection; a log sequence number for recovery. | Data integrity and crash recovery (later session). |
| Pointers to siblings | Next/previous page IDs (used by index pages). | To chain pages into ordered structures. |
The header is small but mighty: without it, the engine couldn't tell where the data ends, how many records are present, or whether the page is corrupted.
The data region โ where records live
The rest of the page holds the actual records (the serialized rows / tuples). The big question is: how do we lay records out, and how do we find a specific one later? The answer depends on whether records are all the same size or not.
Fixed-length vs. variable-length records
| Aspect | Fixed-length records | Variable-length records |
|---|---|---|
| When it happens | Every column is a fixed size (e.g. INT, CHAR(10), DATE). | At least one column varies (e.g. VARCHAR, TEXT, BLOB). |
| Finding the Nth record | Trivial: offset = header_size + N ร record_size. Pure arithmetic. | Hard: you can't compute it โ records have different lengths, so you must track each one's position. |
| Deleting a record | Leaves a fixed-size hole; easy to reuse or mark free. | Leaves a variable hole; causes fragmentation. |
| Layout strategy | Often a simple packed array of records. | Needs a slotted page (Topic 3). |
Suppose every record is exactly 32 bytes, the header is 24 bytes, and the page is 8,192 bytes. To read record number 5 (0-indexed):
# Fixed-length layout: records packed back-to-back after the header. record_size = 32 header_size = 24 n = 5 offset = header_size + n * record_size # 24 + 5*32 = 184 record = page_bytes[offset : offset + record_size] # bytes 184..216
No searching, no extra bookkeeping โ just multiply and slice. This O(1) random
access is the whole appeal of fixed-length records. The catch: most real tables have
a VARCHAR somewhere, so we rarely get this luxury.
You might think "just pack variable records back-to-back too." But then deleting a record in the middle leaves a hole, and inserting a bigger one later forces you to shift everything after it. Worse, any pointer that said "record 3 is at byte 412" instantly breaks when records move. We need a layout that lets records move freely without invalidating their addresses โ which is exactly what slotted pages solve in Topic 3.
A page = a small header (metadata: page id, type, free-space pointers, record count, checksum) + a data region full of records. Fixed-length records give cheap arithmetic addressing; variable-length records need cleverer bookkeeping.
offset = header +
N ร size), but variable-length records can't โ they need a layout that tolerates records
changing size and moving around. That layout is the slotted page.
3 Slotted pages
Imagine a cloakroom at a theatre. You hand over your coat and get a little numbered ticket. The attendant can hang your coat anywhere on the rack, and even move it around to make space โ but your ticket number never changes. When you come back, you show ticket #7, the attendant looks up where coat #7 is hanging right now, and fetches it. A slotted page works exactly like this: each record gets a permanent "ticket" (a slot number), while the record itself can be moved around inside the page freely.
The slotted page is the standard layout for variable-length records, used by PostgreSQL, SQLite, SQL Server, and most others. It solves the two problems from Topic 2 at once: it handles variable sizes, and it lets records move without breaking their addresses.
The layout: grow from both ends
A slotted page grows from both ends toward the middle:
- Right after the header sits the slot array (also called the slot directory or line pointer array). It grows downward from the top. Each slot is a small fixed-size entry โ typically a (offset, length) pair โ that points to one record.
- The actual record data is packed from the bottom of the page upward.
- The free space is the gap in the middle, shrinking from both sides as the page fills.
Here's the picture. The slot array grows down from the header; records grow up from the end; free space is squeezed in between:
byte 0 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ byte 8191
โโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโ
โ HEADER โ SLOT ARRAY โโโโโโโโบ โ free space โ โโโโ RECORDS โ
โ (metadata) โ [s0][s1][s2]... โ (the gap) โ ...R2 R1 R0 โ
โโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโโโโดโโโโโโโโโโโโโโโ
grows โ grows downward โ shrinks โ grows upward
slot s0 โโ โโโ record R0
slot s1 โโผโโโโ each slot = (offset, length) โโโโโโโโบโโโ record R1
slot s2 โโ points to a record โโโ record R2
Each slot is tiny and fixed-size (e.g. 4 bytes: a 2-byte offset + 2-byte length). So even though records vary in size, the slot array is a neat fixed-stride array โ and that gives us back the cheap arithmetic addressing we lost in Topic 2, but at the slot level instead of the record level.
The Record ID โ a stable address
A record is identified not by its byte position, but by its Record ID (RID): the pair (page number, slot number). PostgreSQL calls this the tuple ID (TID/ctid). Because the RID names a slot, not a byte offset, the engine can shuffle records around inside the page and the RID still works โ it just updates the offset stored in the slot.
The slot array adds one level of indirection. Outsiders (indexes, other
pages) hold the stable RID (page, slot). Only the slot knows the
record's real byte offset. Move the record, update the slot โ and every reference stays valid.
This is the same trick that powers virtual memory, file systems, and pointers everywhere in
computing.
Insert, delete, and move โ without breaking RIDs
Let's walk through the operations on a page with three records R0, R1, R2 in slots 0, 1, 2.
Step 1 โ Insert a record. The engine writes the record bytes at the top of the free space (just above the highest record), then adds a new slot pointing at it.
# Insert record R3 of length L free_end = header.free_space_end # top of the record area new_offset = free_end - L # carve L bytes off the free gap page[new_offset : free_end] = R3_bytes slots.append( (offset=new_offset, length=L) ) # this is slot 3 โ RID (page, 3) header.free_space_end = new_offset
Step 2 โ Delete R1. We do not shift other records (that would be expensive and break offsets). Instead we just mark slot 1 as empty โ typically by setting its length to a tombstone value (e.g. length = 0, or offset = a special "dead" marker):
# Delete the record in slot 1 slots[1] = (offset=0, length=0) # tombstone: slot now points to nothing
RID (page, 1) is now invalid, but RIDs of R0 and R2
((page, 0) and (page, 2)) are untouched. The
slot number is not reused immediately, so old references fail safely instead
of silently pointing at a different record.
Step 3 โ Compact (vacuum) to reclaim the hole. When the page gets fragmented, the engine can slide the live records together to merge the free space โ and because RIDs point at slots, it just rewrites each slot's offset afterward:
# Compaction: rewrite live records contiguously, fix up slot offsets write_ptr = end_of_page for slot in slots: if slot.is_live(): write_ptr -= slot.length move record bytes to write_ptr slot.offset = write_ptr # RID (page, i) unchanged โ only the offset moved header.free_space_end = write_ptr
After compaction the records sit in different byte positions, but every RID still resolves correctly because the lookup is always "slot โ current offset." That's the magic.
Updating a record to a larger size is the interesting case: if it still fits in the page's free space, the engine rewrites it (possibly moving it and updating its slot). If it no longer fits, the engine places it on another page and leaves a small forwarding pointer (a redirect) in the original slot โ so the RID still works, it just hops once. PostgreSQL's HOT updates and SQL Server's forwarded records are real-world versions of this.
To find a record you do two lookups instead of one: read the slot, then read the record at the slot's offset. That's one extra memory access โ utterly negligible since the whole page is already in RAM. In exchange you get free movement, easy deletes, and stable addresses. A great trade.
A slotted page = header + a fixed-stride slot array growing down + variable records growing up + free space between them. Records are addressed by RID = (page, slot), never by raw byte offset, so inserts, deletes, moves, and compaction never break existing references.
(page, slot) that indexes and other pages rely on.
4 Byte-level record encoding
When you mail a present, you can't just throw the toy, the card, and the candy loose into an envelope โ the postman wouldn't know where one thing ends and the next begins. So you wrap each item and write a little packing slip saying what's inside and in what order. A database does the same to a row: it carefully wraps each column into bytes, in a fixed order, with little notes (like "this field is empty" or "the name is 5 letters long") so it can be unwrapped perfectly later.
Inside a slot's region sits the actual record โ a single row turned into a flat stream of bytes. Turning a structured row into bytes is called serialization (and turning it back is deserialization). Let's see exactly what those bytes are.
The pieces of a serialized record
A typical record is laid out as: a small record header, then the fixed-length fields, then the variable-length fields. The header usually contains:
- A null bitmap โ one bit per column, set to 1 if that column is
NULL. This is how the engine stores "this field has no value" without wasting space on the value itself. - Sometimes a count of columns, a version tag, or visibility info for concurrency (we'll meet that in a later session on transactions).
Fixed-length fields are easy; variable-length need offsets
Fixed-length fields (like INT = 4 bytes,
BIGINT = 8 bytes) are placed first, back-to-back, so the deserializer
knows each one's position by simple arithmetic.
Variable-length fields (like VARCHAR) are the
problem: their length isn't known in advance. Two common solutions:
- Length prefix: store the length right before the data
(e.g.
[len=5]["Medha"]). To skip to the next field, read the length, then jump that far. - Offset array: keep an array of offsets at the start of the record, one per variable field, pointing to where each field's data begins. This gives O(1) access to any variable field without scanning earlier ones.
If the fixed-length columns come first, their offsets are constant for every row โ the
engine can read an INT column with a hard-coded offset. The unpredictable
variable-length data is pushed to the end, where its messiness doesn't disturb the tidy fixed
part. Real engines (PostgreSQL, InnoDB) physically reorder columns on disk for exactly this
reason, even though you wrote them in a different order in CREATE TABLE.
A concrete byte-layout example
Let's serialize one real row. Take this table and row:
CREATE TABLE users ( id INT, -- 4 bytes, fixed age SMALLINT, -- 2 bytes, fixed name VARCHAR, -- variable length email VARCHAR -- variable length ); -- The row we want to store: INSERT INTO users VALUES (7, 30, 'Medha', NULL);
So id = 7, age = 30,
name = 'Medha' (5 characters), and email = NULL.
Here is one reasonable byte layout (little-endian integers, a 1-byte null bitmap, length-prefixed
strings). Byte offsets are shown on the left:
offset bytes (hex) meaning โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ 0 04 null bitmap = 0000 0100b โโ bit0 id=present, bit1 age=present, bit2 name=present, bit3 email=NULL (the set bit) โโโโโโโโโโโโโโโ fixed-length fields โโโโโโโโโโโโโโโโโโโโโโโโโ 1 07 00 00 00 id = 7 (INT, 4 bytes, little-endian) 5 1E 00 age = 30 (SMALLINT, 2 bytes; 0x1E = 30) โโโโโโโโโโโโโโโ variable-length fields โโโโโโโโโโโโโโโโโโโโโโ 7 05 name length = 5 (1-byte length prefix) 8 4D 65 64 68 61 name data = 'M' 'e' 'd' 'h' 'a' (ASCII) (email is NULL โ bit 3 set in the bitmap โ no bytes stored at all) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ total record size = 13 bytes
Walk through it the way the database would when reading the row back:
- Read byte 0 โ the null bitmap
0x04= binary00000100. Bit 3 is set, soemailisNULLand will occupy zero bytes later โ we skip it entirely. - Read 4 bytes at offset 1 โ
id = 7. - Read 2 bytes at offset 5 โ
age = 30. - Read 1 length byte at offset 7 โ
5, then read 5 bytes โ the string"Medha"forname. emailwas flagged NULL by the bitmap, so there is nothing more to read. Done โ 13 bytes total.
Here's the read path in pseudo-Python, mirroring the steps above:
def read_user(rec): null_bitmap = rec[0] pos = 1 # id: INT, 4 bytes, little-endian id = int_from_bytes(rec[pos:pos+4]) # 0x07000000 โ 7 pos += 4 # age: SMALLINT, 2 bytes age = int_from_bytes(rec[pos:pos+2]) # 0x1E00 โ 30 pos += 2 # name: length-prefixed VARCHAR n = rec[pos]; pos += 1 # length = 5 name = rec[pos:pos+n].decode(); pos += n # "Medha" # email: check the null bitmap (bit 3) before reading anything if null_bitmap & (1 << 3): email = None # NULL โ no bytes consumed else: m = rec[pos]; pos += 1 email = rec[pos:pos+m].decode() return (id, age, name, email)
Real systems are fussier than our tidy example. They often pad
fields so multi-byte values start on aligned addresses (a 4-byte INT
aligned to a 4-byte boundary), which speeds up CPU reads but wastes a few bytes. PostgreSQL adds
a 23-byte tuple header per row; InnoDB stores its own record header and hidden columns (a row
id, transaction id, rollback pointer). So a "tiny" row is never quite as tiny on disk as the raw
column bytes suggest.
A record is bytes laid out as: null bitmap โ fixed-length fields โ variable-length fields (the variable ones found via length prefixes or an offset array). The null bitmap lets NULLs cost almost nothing, and putting fixed fields first makes their positions constant and cheap to read.
(7, 30, 'Medha', NULL) row
showed the whole encode/decode round-trip โ and real engines add alignment padding and
per-row headers on top.
โ Putting it all together
You just followed your data all the way down to the bits. Here's the one-paragraph story that connects all four topics:
Because disks are far slower than memory and the slow part is finding data, a database always reads and writes a fixed-size page (4โ16 KB) instead of a single row. Each page has a small header of metadata and a data region of records; fixed-length records can be addressed by arithmetic, but variable-length records need the slotted page layout โ a fixed-stride slot array growing down, records growing up, and a stable RID = (page, slot) that survives inserts, deletes, moves, and compaction. Each record in a slot is itself a serialized stream of bytes: a null bitmap, then fixed-length fields, then variable-length fields located by length prefixes or offsets. Master this and you understand the literal, physical foundation every higher database layer is built on.
Quick self-check
Why does a database read a whole page instead of a single row?
Because disk I/O is dominated by the cost of finding the data (seek / controller round-trip), not transferring it. Reading a whole page amortises that fixed cost over many rows, so packing ~60 rows per page can mean ~60ร fewer slow disk trips.
What two regions make up a typical page, and what's in the smaller one?
A page header and a data region. The header holds metadata: page id, page type, free-space pointers, record/slot count, and a checksum or log sequence number for integrity and recovery.
Why can't you address a variable-length record with offset = header + N ร size?
Because each record is a different length, so there is no constant size to multiply by. You must track each record's individual position โ which is what the slot array in a slotted page does.
In a slotted page, why does deleting or moving a record not break references to it from indexes?
References use the Record ID (page, slot), not a raw
byte offset. Only the slot stores the record's current offset. Move the record and you update
the slot's offset; the RID stays the same, so every external reference still resolves.
How does a record store a NULL column without wasting space on it?
Via the null bitmap in the record header โ one bit per column. If the bit is set, the column is NULL and no bytes are stored for its value; the deserializer simply skips it.
Why do storage engines place fixed-length fields before variable-length ones?
So the fixed fields sit at constant offsets in every row and can be read with hard-coded arithmetic. The unpredictable variable-length data is pushed to the end, located via length prefixes or an offset array, where it doesn't disturb the tidy fixed part.
๐ References & Further Reading
Class material
- ๐ Original course notes / handout (source sheet) โ open the shared class material for this session.
- DBMS Session 2 โ Storage Engine (the class handout for this session).
Papers, docs & deep dives
- CMU 15-445/645 โ Database Systems (course site) โ the gold-standard university course; the "Database Storage" lectures cover pages, slotted pages, and tuple layout exactly as in this session, with free video lectures and slides.
- PostgreSQL Docs โ Database Page Layout โ the canonical, real-world reference: the page header, the line-pointer (slot) array, item layout, and the heap tuple header described byte by byte.
- Silberschatz, Korth & Sudarshan โ Database System Concepts โ the classic textbook; the storage and file-structure chapters formally cover fixed vs. variable-length records and slotted pages.
- PostgreSQL Docs โ TOAST โ what happens when a record is too big to fit on one page: oversized variable-length fields are compressed and moved out-of-line, a real-world extension of Topic 4.
- MySQL Docs โ InnoDB Row Formats โ how a different production engine encodes records (COMPACT/DYNAMIC formats, null bitmaps, hidden columns), great for comparing against PostgreSQL.
- Alex Petrov โ Database Internals โ a modern, approachable book whose first chapters walk through page structure, slotted pages, and record encoding in depth.