Storage Systems

Slotted Page Layout: Row Storage, Slot Directory, and In-Page Compaction

Open any 8 KB PostgreSQL heap page and you'll find two data structures growing toward each other like teeth closing a gap: a fixed header and a growing slot directory at the top, and variable-length tuples packed from the bottom up. When they meet, the page is full. This is the slotted page (also called a slot-directory or slotted-page layout): the standard on-disk organization for storing variable-length records inside a fixed-size disk block.

Its central trick is a level of indirection. A record's persistent address is not a byte offset but a slot number, an index into a small array of (offset, length) entries. Because the slot number never changes while the physical bytes move freely, the page can compact its free space, grow a row, or delete a tuple without invalidating a single external pointer or index entry.

  • Also known asSlot directory / slotted-page organization
  • Problem solvedStoring variable-length records in fixed-size pages
  • Record address(page_id, slot_number) = RID/TID/ctid
  • Lookup costO(1) offset dereference via slot directory
  • Typical page size4 KB - 16 KB (PostgreSQL 8 KB, SQLite up to 64 KB)
  • Key propertyIndirection: slot number stable while bytes move

Interactive visualization

Press play, or step through manually. The visualization is yours to drive — try it before reading on.

Open visualization fullscreen ↗

Watch the 60-second explainer

A condensed visual walkthrough — narrated, captioned, under a minute.

What It Is and the Problem It Solves

A storage engine reads and writes the disk in fixed-size blocks, typically 4-16 KB, because that is the unit the buffer pool, WAL, and I/O subsystem operate on. But real rows are variable-length: a VARCHAR, a NULL bitmap, a TEXT column, or a JSONB blob all make tuples different sizes, and they grow or shrink on UPDATE. Packing such records naively creates two problems. First, deleting a middle tuple leaves a hole (fragmentation). Second, if a record's address is a raw byte offset, moving bytes to reclaim that hole would invalidate every index entry and foreign pointer that referenced it.

The slotted page solves both with one idea: separate the logical address from the physical location. Each record is reachable through a slot, a fixed-size directory entry holding the tuple's current byte offset and length. External references store the slot number, never the offset. The engine is then free to relocate the actual bytes inside the page at will, updating only the private slot entry. This is the workhorse layout for heap files and B-tree leaf pages in nearly every relational database.

How It Works, Step by Step

A slotted page has three regions. At the top sits a small page header (checksum, LSN, free-space pointers, slot count). Just below it, the slot directory grows downward: an array of entries, one per record, each typically holding an offset and a length (in PostgreSQL, a 4-byte ItemId). From the bottom of the page, the actual tuple data grows upward. The gap between the last slot and the highest tuple is the free space.

INSERT(record r):
  if free_space < len(r) + sizeof(slot): return PAGE_FULL
  data_top    -= len(r)          // carve space from the bottom
  write r at offset data_top
  slot[n]     = (data_top, len(r))
  n           += 1
  return slot_number n-1

DELETE(slot i):
  mark slot[i] as dead (offset = 0 / tombstone)
  // bytes stay until compaction; slot number is NOT reused

LOOKUP(slot i):
  (off, len) = slot[i]
  return bytes[off .. off+len]     // O(1)

Deletes leave a tombstone slot so the slot number is never reused (dangling index entries must resolve to "dead"). When accumulated holes prevent an insert despite enough total free bytes, the page runs compaction: slide all live tuples together, rewrite each slot's offset, and coalesce the free space into one contiguous block.

Complexity and a Worked Step Trace

Let the page hold up to n records. Lookup by slot number is O(1): index the directory, read (offset, length), return the bytes. Insert is O(1) amortized (write at the data pointer, append a slot). Delete is O(1) (tombstone the slot). Compaction is O(n) in the live tuple count and is triggered lazily, only when fragmented free space blocks an insert. Space overhead is one slot per record: 4 bytes in PostgreSQL, a few bytes in SQLite's cell-pointer array, negligible against typical row widths.

Page (100 bytes, header=10). Insert A(20), B(15), C(10):
  slots:  [ (80,20) (65,15) (55,10) ]        // grows down from 14
  data:   ...............[C 55-65][B 65-80][A 80-100]
  free = 55 - (10 + 3*4) = 33 bytes

DELETE slot 1 (B):  slot[1] = TOMBSTONE
  live bytes now A(20)+C(10)=30, but the 15-byte B-hole is trapped.

INSERT D(28):  33 free >= 28+4? contiguous free is only 33-15=... 
  fragmented -> COMPACT: slide C up beside A, rewrite slot[2].
  Now one 45-byte free block -> D fits, gets slot 3.

The invariant the engine maintains: free_space = data_top - (header_end + num_slots * slot_size), and every live slot's [offset, offset+length) range is disjoint from every other live tuple.

Real-System Usage

PostgreSQL is the canonical example: every heap page is slotted, with an array of 4-byte ItemId line pointers and tuples filling from the end. A row's address is its ctid = (block_number, item_offset), the tuple identifier that indexes point to. VACUUM reclaims dead tuples and can trigger page compaction; pageinspect lets you read the raw slot array. SQLite uses the same idea on B-tree pages: a cell-pointer array at the top, cells (rows) packed from the bottom, and freeblocks tracking reclaimable holes, defragmented on demand.

IBM DB2, Oracle (row directory in each block), SQL Server (slot array at the end of each 8 KB page), MySQL/InnoDB (records linked in a page with a directory of slots for binary search) all use variants. The stable slot number is what makes secondary indexes practical: an index entry can store a compact RID/TID and survive intra-page reorganization. It is one of the most widely deployed data-layout patterns in production software.

Comparison to Alternatives and the Tradeoff

The simplest alternative is a fixed-length slot array: divide the page into equal cells and index directly. It has no fragmentation and no directory offsets, but forces padding to the widest row, wasting space and failing outright for TEXT/BLOB columns. The slotted page trades that padding for one indirection and occasional O(n) compaction, in exchange for true variable-length support.

A packed append heap (no directory, addresses are byte offsets) is compact but brittle: any compaction rewrites external pointers, so indexes must be rebuilt or fixed up. Log-structured merge (LSM) storage sidesteps in-page mutation entirely by writing immutable sorted segments and compacting in the background, which excels at write-heavy workloads but pays read amplification across levels and needs Bloom filters to prune. The slotted page's sweet spot is read-mostly, update-in-place workloads where stable record identity and O(1) point lookup matter, exactly the profile of a classic B-tree-indexed relational table.

Pitfalls and Significance

The chief failure modes are subtle. Slot-number reuse is dangerous: reuse a tombstoned slot for a new row and a stale index entry now silently points at the wrong tuple; engines therefore keep tombstones until it is provably safe (PostgreSQL waits for VACUUM after no transaction can see the dead tuple). Grow-in-place UPDATE is a classic trap: if a row grows past its old length, it may not fit its hole. The engine either compacts, or moves the tuple and leaves a forwarding pointer (a redirect slot), which can degenerate into a chain and hurt locality (Oracle's "row migration/chaining," MySQL's overflow pages). Fragmentation and lazy compaction mean total free space can exceed the largest usable contiguous run.

Its significance is durability of design: proposed in the classic transaction-processing literature (Gray and Reuter's Transaction Processing, 1993, codifying practice from 1970s System R and IMS) and canonized in Hellerstein, Stonebraker, and Hamilton's Architecture of a Database System (2007), the slotted page has survived five decades essentially unchanged because indirection cleanly decouples what a record is from where its bytes are.

Slotted page vs. alternative in-page record organizations
LayoutVariable-length recordsRecord address stabilityIn-place compactionLookup cost
Slotted pageYes, nativelyStable (slot number)Yes, transparent to indexesO(1) via slot directory
Fixed-length array of slotsNo (padding wastes space)Stable (array index)N/A (no fragmentation)O(1) direct index
Append-only log / packed heapYesFragile (byte offset)Requires rewriting pointersO(1) but pointers break on move
Log-structured (LSM) segmentYesImmutable segmentsVia background compaction/mergeO(log n) across levels

Frequently asked questions

Why not just store a record's byte offset as its address instead of a slot number?

Because byte offsets change whenever the page is compacted or a tuple is moved to reclaim a hole. If indexes and foreign references stored raw offsets, every reorganization would invalidate them. The slot number stays constant while the offset in the private slot entry is updated, so external pointers never break. That indirection is the whole point of the design.

What happens to the slot when a row is deleted?

The slot is marked dead (a tombstone), not removed, and its slot number is not reused. The tuple bytes usually remain until a later compaction or vacuum reclaims them. Keeping the tombstone lets stale index entries that still point to that slot resolve to 'dead' rather than accidentally reading a different, newer row that reused the number.

When does a page actually compact, and how expensive is it?

Compaction is lazy: it runs only when an insert or in-place update cannot find a contiguous free run despite there being enough total free bytes (fragmentation). It slides all live tuples together and rewrites each live slot's offset, costing O(n) in the number of live tuples on that one page. Since it touches a single in-memory page, it is cheap relative to I/O.

What is a ctid, RID, or TID?

They are the same idea under different names: a physical record identifier of the form (page/block number, slot number). PostgreSQL calls it ctid, DB2/SQL Server call it a RID, and 'TID' (tuple identifier) is the generic term. Secondary indexes store these to point at heap tuples; the slot number's stability is what makes them reliable across page reorganization.

How is a slotted page different from a B-tree node?

They are complementary, not competing. A B-tree defines the ordered tree structure and search path across pages; the slotted page defines how variable-length keys and records are physically laid out inside each individual node/leaf page. In practice B-tree leaf pages ARE slotted pages, often with the slot directory kept in sorted order so the engine can binary-search keys within the page.

Does an UPDATE that grows a row cause problems?

It can. If the new row no longer fits in its old space and there is not enough contiguous free room even after compaction, the engine must relocate it. Some systems move it elsewhere on the page; if it must leave the page, they leave a forwarding/redirect pointer or use overflow pages. Chains of these (row migration/chaining) add extra I/O and are a known performance and fragmentation concern.