Internals · StorageAtlasDB V3 · Slotted Pagesslotted pageslot directoryline pointerfragmentationcompaction

Slotted Pages

Variable-size records inside a fixed-size page: a slot directory growing down from the header, records growing up from the end, free space in the middle. Records move, slots stay — which is what keeps every index entry pointing at (page, slot) valid. Then fragmentation, compaction, and what happens when an update no longer fits.

▶ InteractiveInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    Records are 40 to 400 bytes and change size on update; the page is exactly 8192 bytes; and index entries all over the database hold the address of each record. Where inside the page does record k live, and how does anyone find it after the page has been edited a thousand times?

  2. Naive solution

    Pack records back to back from the start of the page. Record k is found by walking k length words. An index stores the byte offset.

  3. Why it breaks

    Delete record 3 and either a hole stays forever, or records 4… slide down and every stored offset for them is wrong — in every index, on other pages, in every running transaction. Grow record 5 by 10 bytes and the same happens. Finding record 70 walks 69 headers.

  4. Better idea

    Separate the *name* of a record from its *position*. Give each record a small fixed-size slot at a fixed place in the page; the slot holds the current offset. Everyone outside the page refers to the slot number; only the page knows the offset, and it may change it freely.

  5. Internal mechanism

    The slotted page: a 24-byte header, a slot directory of 4-byte entries growing down from byte 24, records growing up from byte 8192, free space in between bounded by lower and upper. Insert: write the record at upper − len, append a slot. Delete: mark the slot dead, leave a hole. Update that grows: write a new copy, re-point the slot. Compact: slide live records together, rewrite offsets, keep slot numbers.

  6. Trade-offs

    4 bytes per record of directory, one indirection per access, holes that need compaction, and a page that can be "full" with 30% of it in holes. In exchange, stable addresses: indexes and forwarding pointers survive every intra-page move.

  7. Real database

    PostgreSQL: ItemIdData line pointers (15-bit offset, 2-bit flags, 15-bit length) under a 24-byte PageHeaderData; pruning compacts the page in place; an update that fits stays on the page as a HOT chain, otherwise the old tuple's t_ctid forwards to a new page. InnoDB: a page directory of 2-byte slots at the *end* of the page, each owning 4–8 records linked in key order; updates that change size delete and reinsert within the page; pages split when nothing fits.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

A table of contents inside every page

Each page keeps a short list at its front — the slot directory — that says where every record on the page currently is. The records themselves are stacked from the back. The gap between the list and the stack is free space.

Anyone who needs to point at a record points at its slot number, never at its position. So the page can shuffle records around to make room, and no pointer outside the page breaks.

Why variable-size records are hard

If every record were 80 bytes, a page would be an array: record k at 24 + 80k, no directory, no headers, deletion by a bitmap. Rows are not like that. A TEXT column makes every record a different length, an UPDATE makes the same record a different length tomorrow, and NULL makes a column vanish. So a page cannot compute where record k is; it has to *store* where record k is. And whatever it stores is referenced from outside — every index entry on the table holds a record address — so the answer must survive the page being edited.

The naive layout, records packed from the front, fails on exactly that. Deleting record 3 leaves a permanent hole or shifts records 4 through 99 and breaks every pointer to them. The requirement is a name for a record that does not change when its bytes move. That name is the slot.

The slotted page

A slotted page is three regions and two pointers. The header (24 bytes in PostgreSQL) holds the checksum, the LSN, flags, and lower and upper. The slot directory starts right after the header and grows *down* the page: slot i is at 24 + 4i and holds the record's offset and length (and two flag bits). The records start at the *end* of the page and grow *up*: the first record inserted is at 8192 − len, the next just below it. Between lower (end of the directory) and upper (start of the records) lies the free space, and upper − lower is exactly how much a new record plus its slot may take.

The two regions grow towards each other so that neither has to be sized in advance: a page of 100 short records and a page of 12 long ones use the same layout. A record is addressed as (page, slot); the slot's offset is private to the page. The consequence that makes everything else possible: the page may move a record's bytes as long as it updates the slot. Nobody outside notices.

A slotted page after four inserts, one delete and one update that grew. PostgreSQL vocabulary: line pointers, pd_lower, pd_upper.
PAGE #4821 (8192 bytes)
┌──────────────────────────────────────────────────────┐ 0
│ header  checksum · LSN · flags · lower=40 · upper=7412│
├──────────────────────────────────────────────────────┤ 24
│ slot 0 → (8115, 77)   slot 1 → (7412, 250)  [moved] │
│ slot 2 → DEAD         slot 3 → (7662, 173)           │ ↓ grows down
├──────────────────────────────────────────────────────┤ lower = 40
│                                                      │
│                    free space  7372 B                │
│                                                      │
├──────────────────────────────────────────────────────┤ upper = 7412
│ record 1 (new version, 250 B)                        │ ↑ grows up
├──────────────────────────────────────────────────────┤ 7662
│ record 3 (173 B)                                     │
├──────────────────────────────────────────────────────┤ 7835
│ ░░░ hole: old record 1 (200 B) + deleted record 2 ░░░│
├──────────────────────────────────────────────────────┤ 8115
│ record 0 (77 B)                                      │
└──────────────────────────────────────────────────────┘ 8192

Insert, delete, update

Insert a record of len bytes: check upper − lower ≥ len + 4; write the bytes at upper − len; set upper to that; append a slot (upper, len); lower += 4. Cost: the record plus four bytes, and no other record moved. Delete slot i: set its flags to dead, zero its offset and length. The bytes stay where they are — a hole — and the slot stays in the directory, because slot i may still be named by an index entry or by a concurrent scan, and "dead" is the correct answer to give them. Reusing the slot number for a different record would make those references silently point at the wrong row.

Update is where the design earns its keep. If the new version is the same size or smaller, overwrite in place (the tail becomes a small hole). If it is larger and the record happens to border the free space, extend it. Otherwise, write the new version into the free space and re-point the slot: slot i now says (new offset, new length), the old bytes are a hole, and every index entry that says (page, slot i) is still correct. The record moved; its name did not.

The four operations on a slotted page. `slots[i]` is the 4-byte directory entry.
1insert(rec):
2 if upper - lower < len(rec) + 4: return FULL # ask the free-space map for another page
3 upper -= len(rec); page[upper : upper+len(rec)] = rec
4 slots.append((upper, len(rec))); lower += 4
5 return slot_no = len(slots) - 1
6
7delete(i): slots[i] = DEAD # bytes stay: a hole
8
9update(i, rec):
10 off, old = slots[i]
11 if len(rec) <= old: page[off : off+len(rec)] = rec; slots[i] = (off, len(rec))
12 elif off == upper and len(rec) - old <= upper - lower:
13 upper -= len(rec) - old; page[upper:] = rec; slots[i] = (upper, len(rec))
14 elif len(rec) <= upper - lower: # no new slot needed
15 upper -= len(rec); page[upper:] = rec; slots[i] = (upper, len(rec)) # old bytes: hole
16 elif fits_after_compaction: compact(); retry
17 else: return MOVE_TO_ANOTHER_PAGE
18
19compact(): # slot numbers never change
20 cursor = 8192
21 for i in live slots ordered by offset descending:
22 cursor -= slots[i].len; move bytes to cursor; slots[i].off = cursor
23 upper = cursor

Fragmentation and compaction

Every delete, shrink and move leaves a hole, and holes are useless until they are adjacent to the free space. A page therefore has two numbers that both deserve the name "free": the contiguous upper − lower, which an insert can use, and the fragmented bytes scattered in holes, which it cannot. A page with 7 KB of holes and 60 bytes between lower and upper rejects a 100-byte insert. The page inspector shows both, and the moment an insert fails with plenty of fragmented space is the moment compaction is invented.

Compaction slides the live records together at the end of the page — highest offset first, so the picture keeps its order — and rewrites the offset in each slot. upper moves down to the new start of the records; the holes are gone; the contiguous free space is now everything that was free. Dead slots stay dead and keep their numbers. No index, no scan, no other page is involved: compaction is a purely local operation, which is why it can be done lazily and cheaply, typically the first time an insert or update would otherwise fail.

What compaction does not do is shrink the slot directory. Dead slots are only reclaimed when it is provably safe — in PostgreSQL, when VACUUM has confirmed no index entry points at them, after which they become LP_UNUSED and may be reused; the last slots can be truncated. A table with heavy delete churn on a few pages can end up with pages that are mostly directory.

  • free = upper − lower is what an insert can use now.
  • fragmented = (8192 − upper) − Σ live record lengths is what only compaction can recover.
  • Compaction rewrites offsets, never slot numbers; it is local to the page.
  • Dead slots outlive compaction until the engine knows nothing references them.

When the update no longer fits: PostgreSQL

PostgreSQL implementation

PostgreSQL never overwrites a live tuple, because an older transaction may still need to see it: every update is an insert of a new version plus an xmax on the old one. The question is only *where* the new version goes. If no indexed column changed and the page has room (after pruning its holes), the new tuple goes on the same page and the old tuple's t_ctid points to it: a heap-only tuple (HOT). Indexes still point at the old slot, and a lookup follows the chain — so no index is touched by the update at all. This is the single biggest reason to keep fillfactor below 100 on hot tables: room for HOT versions on the same page.

If the new version does not fit even after PageRepairFragmentation, or an indexed column changed, it goes to another page chosen from the free-space map. The old tuple stays, dead, with its t_ctid forwarding to the new location, and *every* index on the table gets a new entry pointing at it. The forwarding pointer keeps in-flight scans and stale index entries correct; VACUUM eventually removes the dead tuple and the index entries that led to it. Values that would not fit in a page at all never reach the page: beyond about 2 KB, TOAST compresses them or moves them to a separate table and leaves a pointer.

Same-page (HOT) versus cross-page update. Line pointers are the slots; LP_REDIRECT is a slot that points at another slot on the same page after pruning.
HOT update (fits, no indexed column changed)        cross-page update
page 4821                                          page 4821            page 5090
 lp 3 → tuple v1 (xmax set) ─ctid─▶ lp 7 → v2       lp 3 → v1 (dead) ─ctid──▶ lp 2 → v2
 index entry still says (4821,3): follows chain     index: old entry (4821,3) + NEW entry (5090,2)
 after pruning: lp 3 = LP_REDIRECT → 7, v1 gone     after VACUUM: lp 3 = LP_DEAD, old index entry removed

When the update no longer fits: InnoDB

MySQL / InnoDB implementation

InnoDB rows live in the leaf pages of the clustered index, in primary-key order, linked by each record's next_record offset; the page directory at the *end* of the page holds 2-byte slots that each own a group of 4–8 records, so a lookup binary-searches the directory and then walks a few records. An update that does not change the row's size is done in place — the previous version has already been copied to the undo log, and DB_ROLL_PTR points at it, so no second copy is needed in the page. An update that changes the size is done as a delete and reinsert within the page, which keeps key order; if the reinsert does not fit, InnoDB reorganizes the page (its compaction) and tries again.

When even a reorganized page cannot hold the row, "another page" means a B+ tree page split: half the records move to a new page and the parent gets a new separator key (B+ Tree Internals: Pages, Splits, Merges). Secondary indexes do not notice — they store the primary key value, not a page and slot — so a row moving between clustered-index pages costs no secondary-index maintenance, the mirror image of PostgreSQL's cost. InnoDB reserves 1/16 of each page on sequential inserts precisely so that later size-changing updates usually stay put; columns that outgrow the row go to overflow pages behind a 20-byte pointer.

A record grows and no longer fits where it was
SituationGeneral slotted pagePostgreSQLInnoDB
Same size or smallerOverwrite in placeNever in place: new tuple, old gets xmax (HOT if no indexed column changed)In place; old version already in undo
Larger, fits on the pageMove within page, re-point the slotHOT: new tuple on same page, old ctid → new; indexes untouchedDelete + reinsert within the page, key order kept
Fits only after compactionCompact, then movePrune the page (PageRepairFragmentation), then HOTReorganize the page, then reinsert
Does not fit at allMove to another page; leave a forwarding pointerNew tuple on a page from the FSM; old tuple forwards via ctid; every index gets a new entryPage split in the clustered index; secondary indexes unaffected (they hold the PK)
Larger than a pageTOAST: compress, then out-of-line in the toast tableOverflow pages, 20-byte pointer in the row

Key points

  • A slotted page separates a record's name (its slot number) from its position (an offset the page may change): slots grow down from the header, records grow up from the end, free space is upper − lower.
  • References from outside the page — index entries, ctids — hold (page, slot), so the page can move records freely as long as it updates the slot.
  • Delete marks a slot dead and leaves a hole; an update that grows writes a new copy and re-points the slot; slots are never renumbered.
  • Two kinds of free: contiguous (usable now) and fragmented (holes). Compaction slides live records together, rewrites offsets, keeps slot numbers, and is purely local to the page.
  • PostgreSQL: HOT chains on the same page when possible, otherwise forwarding via t_ctid to another page plus a new entry in every index. InnoDB: in-place or same-page reinsert, otherwise a B+ tree split; secondary indexes are unaffected.

Page inspector: slotted page

Slotted page
8192-byte page, 24-byte header, 4-byte slots. Insert, update, delete and compact — and watch that slot numbers never change while byte offsets do.
PAGE #4821 · byte 0 at topheader0slots × 624free 7477 B48slot 5 · 105 Bslot 4 · 123 Bslot 3 · 152 Bslot 1 · 49 Bslot 0 · 41 B8192
free bytes
7477
fragmented
0
live records
6
dead slots
0
lower / upper
48 / 7525
Six records inserted. Slots grow down from the header, records grow up from byte 8192, free space is the gap between.
Slot directory (slot → offset, length)
Selected: header

Page header, 24 B: checksum, LSN of the last change (for WAL ordering), lower = 48 (end of the slot directory), upper = 7525 (start of record data), flags. Read this and you know the free space without touching a record.

Educational simulation — page sizes, costs and counters are modelled, not measured from a real engine.

When to use — and when not

Use it when
  • The slotted layout fits any page that holds variable-size records that are updated in place and referenced by address from elsewhere — heap tables and B+ tree leaves alike.
  • Stable slot numbers fit engines with many secondary indexes pointing at physical locations (PostgreSQL), where every relocation would otherwise be an index write.
Avoid it when
  • It does not fit immutable pages that are written once and never updated (SSTable blocks, columnar files), where a simple offsets-at-the-end footer is enough and holes never arise.
  • It does not fit fixed-size records where 24 + k × size addresses record k directly and a bitmap marks deletions.

Failure modes

  • A page that is "full" with a third of it in holes because compaction only runs when an insert fails — and every insert after that goes to a new page, spreading the table out.
  • Updates on a table with fillfactor = 100 never find room for a HOT version, so every update touches every index (PostgreSQL).
  • Reusing a dead slot number too early, so an old index entry now points at an unrelated row — the bug the dead-slot rule exists to prevent.
  • Delete-heavy churn on a few pages leaving directories full of dead slots, a form of bloat that only VACUUM (or a rebuild) recovers.
  • Wide rows that fit nothing else on the page, so every update forces a cross-page move and an index write.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.