๐Ÿ“š Study Notes / Home / DBMS / Session 2
Session 02 ยท Storage Engine

The Storage Engine โ€” Pages & Records

In Session 1 we looked at the big picture: how a database is organised into layers, from the query parser at the top down to the bits sitting on disk. Now we drop all the way to the bottom and ask the most physical question of all: how does a database actually store your data on a disk? We assume you've studied none of this before. Every topic starts with a tiny "explain like I'm 5" story, then we build up slowly with byte-level diagrams and real code. Take it slow โ€” by the end you'll understand exactly how a single row of a table turns into bytes on a page.

โฑ 22 min read๐Ÿ“– 4 topics

1 Why databases think in pages


Explain like I'm 5

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:

OperationRough latencyRelative speed
Read 1 byte from RAM~100 nanoseconds1ร— (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:

The big idea

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.

Page vs. block โ€” a vocabulary warning

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.

Worked example: rows-per-read vs. total I/O

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.
Key takeaway

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.

Recap Disks are thousands of times slower than RAM, and the slow part is finding data, not transferring it. So databases read and write in fixed-size pages (4โ€“16 KB) rather than single rows. The page is the fundamental unit of database I/O, and packing many rows per page slashes the number of slow disk trips.

2 The anatomy of a page


Explain like I'm 5

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:

FieldWhat it storesWhy it's needed
Page ID / numberThis page's own identity within the file.So pages can reference each other.
Page typeData page? Index page? Free-space map?The engine treats different page types differently.
Free-space pointersWhere the used data ends / free area begins.To know where the next record can go.
Record / slot countHow many records this page currently holds.To iterate over the page's contents.
Checksum / LSNA checksum for corruption detection; a log sequence number for recovery.Data integrity and crash recovery (later session).
Pointers to siblingsNext/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

AspectFixed-length recordsVariable-length records
When it happensEvery column is a fixed size (e.g. INT, CHAR(10), DATE).At least one column varies (e.g. VARCHAR, TEXT, BLOB).
Finding the Nth recordTrivial: 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 recordLeaves a fixed-size hole; easy to reuse or mark free.Leaves a variable hole; causes fragmentation.
Layout strategyOften a simple packed array of records.Needs a slotted page (Topic 3).
Worked example: a fixed-length page

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.

The trap with variable-length data

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.

Key takeaway

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.

Recap Every page splits into a metadata header and a data region. Fixed-length records can be addressed by simple arithmetic (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


Explain like I'm 5

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 big idea

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.

Walkthrough: insert โ†’ delete โ†’ compact

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.

Why the indirection is worth a tiny cost

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.

Key takeaway

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.

Recap The slotted page handles variable-length records by separating identity from location: a fixed-size slot array (growing down) maps each record's stable slot number to its current byte offset, while records pack upward from the page end. Insert appends a slot, delete tombstones a slot, and compaction slides records and rewrites offsets โ€” all without invalidating the RID (page, slot) that indexes and other pages rely on.

4 Byte-level record encoding


Explain like I'm 5

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.
Why put fixed fields before variable 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:

  1. Read byte 0 โ†’ the null bitmap 0x04 = binary 00000100. Bit 3 is set, so email is NULL and will occupy zero bytes later โ€” we skip it entirely.
  2. Read 4 bytes at offset 1 โ†’ id = 7.
  3. Read 2 bytes at offset 5 โ†’ age = 30.
  4. Read 1 length byte at offset 7 โ†’ 5, then read 5 bytes โ†’ the string "Medha" for name.
  5. email was flagged NULL by the bitmap, so there is nothing more to read. Done โ€” 13 bytes total.
Worked example: deserializing in code

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 engines add alignment & overhead

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.

Key takeaway

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.

Recap Serializing a row means flattening it to bytes: a null bitmap records which columns are NULL (so they take zero space), fixed-length fields go first at constant offsets, and variable-length fields go last, located by length prefixes or an offset array. Our 13-byte (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

Papers, docs & deep dives