Blog
About

© 2026 Uzair Tariq

← Back to blog

What a database B-tree really looks like on disk

August 5, 2026DatabasesB-TreesPostgresqlStorage EnginesSystem Design

I found B-trees much easier once I stopped treating them as only a data-structures diagram. A database B-tree is a set of fixed-size pages inside an index file. Each page is one node in the tree, and a lookup is a sequence of page reads that narrows down to an actual row.

A page is not a file

An index file can be gigabytes in size. The database divides it into fixed-size pages, often 4 KB or 8 KB. It can then cache, read, overwrite, and lock one page at a time. A B-tree node is a page inside that larger index file.

ONE B-TREE INDEX FILE

page 0  -> root node
page 1  -> internal node
page 2  -> leaf node
page 3  -> leaf node

Internal pages route a search

Internal pages hold separator keys and references to child pages. They do not need the full row data. A separator only tells the database which child range could contain the requested key.

INTERNAL PAGE

[ 100 | 150 ]
   |      |     |
   v      v     v
 <100  100-149  >=150

search 127
  -> follow the middle page reference

A page with n separator keys has n + 1 child references. Each reference is a physical location for another page in the same index file.

Compact separators make the tree shallow

Internal pages only route the search, so they can sometimes abbreviate a separator key. If the left child holds alice keys and the right child holds bob keys, the parent may only need enough of a boundary to tell those ranges apart. The full keys remain in the leaf pages.

PARENT PAGE

[ b ]
  |   |
  v   v
alice...  bob...

shorter separators
  -> more references fit in one page
  -> fewer tree levels

Leaf pages lead to the row

A lookup reaches a leaf page, where the index has the complete indexed key and either the value itself or a pointer to the actual row. In PostgreSQL, a normal B-tree index usually points into the table's heap storage.

POSTGRESQL LOOKUP

B-tree leaf entry
  user_id 7
    -> heap page 42, slot 3

heap page 42
  -> actual row version

The index pages route within the index file. The leaf entry then routes to a heap page. Every page may already be in RAM, or may need to be read from disk.

PostgreSQL separates indexing from row versions

PostgreSQL uses heap pages for table rows and usually B-trees for indexes. An UPDATE normally creates a new row version rather than overwriting the existing tuple, because an older transaction may still need to see the old version.

UPDATE user_id 7

old heap tuple
  status = active

new heap tuple
  status = inactive

older transaction -> can see old version
newer transaction -> sees new version

VACUUM later reclaims versions that no transaction can see. This is PostgreSQL's MVCC design. It helps concurrent reads and writes, but the B-tree index itself still uses mutable pages and can still split or require internal coordination.

B-trees and LSM-trees pay in different places

B-TREE
  WAL -> find leaf page -> update it in place

LSM-TREE
  WAL -> memtable -> immutable SSTables
  later -> merge SSTables through compaction

Both designs may use a write-ahead log for crash recovery. Their normal storage path differs: B-trees update existing pages, while LSM-trees create new sorted files and merge them later.## Two ideas that soften B-tree updates

Copy-on-write B-trees write replacement pages instead of changing live pages. Updating a leaf requires replacement copies of the ancestors on the path to the root. After an atomic root switch, new readers see the new tree and existing readers can still use the old pages.

COPY-ON-WRITE UPDATE

old root -> old internal -> old leaf

new root -> new internal -> new leaf
  -> atomically make new root current

Fractal trees take a different approach. They keep B-tree pages but put buffers in internal nodes. Updates wait as small messages, then move downward to one child in batches. This borrows the LSM idea of batching writes without switching to SSTable files.

BUFFERED B-TREE

root buffer
  -> batch for one child
child buffer
  -> later batch for one lower child
leaf page
  -> apply updates

The physical mental model I will use

index file
  -> fixed-size index pages
  -> root to internal to leaf
  -> leaf entry points to heap tuple

table file
  -> heap pages
  -> row versions for MVCC

Once I see a B-tree as pages, offsets, cache reads, and small mutations, the trade-offs make more sense. Fast lookup comes from following a short, highly branched path. The cost is that writes may alter shared pages, split them, and need careful recovery and concurrency control.

These are my personal learning notes from Designing Data-Intensive Applications by Martin Kleppmann.

 

Previous

← How B-trees keep database indexes fast and balanced

Next

B-trees vs LSM-trees: where each storage engine pays the cost→