On This Page

What Are B-Trees?

A B-Tree is a self-balancing search tree designed for systems that read and write large blocks of data at a time โ€” like hard drives, SSDs, and network storage. While a binary search tree (BST โ€” a tree where each node stores one key and has at most two children, with all left-subtree keys smaller and all right-subtree keys larger) stores one key per node and has two children, a B-Tree node can hold many keys and have many children.

Think of a library's card catalog. Each drawer (node) contains many cards (keys) arranged in alphabetical order. The labels on the front of each drawer tell you which range of topics it covers โ€” so you know exactly which drawer to open without checking every single one. You might only need to open 2-3 drawers to find any book in the entire library. That's how a B-Tree works: each node contains a sorted range of keys, and child pointers (references to subtrees) tell you which child to visit next.

A Brief History

Rudolf Bayer and Edward McCreight invented the B-Tree in 1970 at Boeing Scientific Research Labs, publishing it in 1972. They were solving a practical problem: how to efficiently search and maintain large ordered datasets stored on slow magnetic disk drives. The "B" probably stands for "Bayer," "balanced," "Boeing," or "broad" โ€” the original paper never says, and both inventors have declined to clarify. It's computer science's longest-running naming mystery.

The B-Tree was revolutionary because it minimized the number of disk accesses needed for any operation. Before B-Trees, databases used structures like ISAM (Indexed Sequential Access Method) that degraded over time and required periodic reorganization. B-Trees stay balanced automatically โ€” every path from root to leaf has the same length, always.

The Key Properties

A B-Tree of order m (also called minimum degree t, where m = 2t) follows these rules:

๐Ÿ’ก Terminology Note Different textbooks define "order" differently. Some say order m means max m children (Knuth's definition). Others say order m means max m keys. Always clarify which convention is being used. This page uses Knuth's definition: order m = max m children = max m-1 keys.

Why Not Just Use BSTs?

Binary search trees work great when everything lives in RAM (Random Access Memory โ€” the fast, volatile memory where running programs store their data). Every pointer dereference takes nanoseconds. But databases store data on disk (HDDs or SSDs), and disk access is orders of magnitude slower:

Every time you follow a pointer to a child node in a tree, that's potentially a disk read (a seek โ€” the physical operation of reading data from a specific location on disk). A balanced BST with 1 million keys has about 20 levels (logโ‚‚(1,000,000) โ‰ˆ 20), meaning 20 disk reads to find a single value. At 10ms per read on an HDD, that's 200ms for one lookup. Unacceptable for a database handling thousands of queries per second.

A B-Tree with order 1000 storing the same million keys? About 2 levels (logโ‚โ‚€โ‚€โ‚€(1,000,000) โ‰ˆ 2). Two disk reads. 20ms total. That's a 10ร— speedup, and the root is usually cached in RAM, so in practice it's often just 1 disk read.

๐Ÿ’ก The Disk I/O Insight B-Trees minimize the number of disk reads by packing many keys into each node. Reading 1 byte and reading 4KB from disk takes roughly the same time (dominated by seek time โ€” the time to position the read head). So you might as well fill each node with as many keys as will fit in one disk block (typically 4KB or 8KB). A 4KB block can hold ~500 keys with their pointers, giving an order-500 B-Tree. Three levels of that covers 500ยณ = 125 million keys.

How They Work

Search

Searching a B-Tree is similar to a BST search, but at each node you do a binary search within the node's sorted keys (or linear scan for small nodes) to find either the target key or the right child pointer to follow:

  1. Start at the root.
  2. At the current node, binary search for the target key among the node's keys.
  3. If found โ†’ return it.
  4. If not found โ†’ follow the child pointer between the two keys that bracket the target value. If target < all keys, follow the leftmost child. If target > all keys, follow the rightmost child.
  5. Repeat until you find the key or reach a leaf without finding it.

Tree height is O(logm n) where m is the order โ€” the base of the logarithm is much larger than 2, making the tree extremely shallow.

Insertion

New keys always get inserted into leaf nodes. B-Trees grow from the bottom up, not from the top down.

  1. Find the right leaf: Search down the tree to find which leaf should contain the new key.
  2. Insert in sorted order: Add the key to the leaf, maintaining sorted order within the node.
  3. Check for overflow: If the leaf now has m keys (one too many), it splits:
    • The median key (the middle key) moves up to the parent node.
    • The node splits into two half-full nodes: one with keys below the median, one with keys above.
  4. Propagate upward: If the parent is now overfull, it splits too. This can cascade all the way up to the root.
  5. Root split: If the root itself splits, a new root is created with the median key and two children. This is the only way the tree grows taller โ€” the tree gains one level of depth.

The beauty of this scheme: the tree stays perfectly balanced because growth happens at the root, not the leaves. Every leaf is always at the same depth.

Deletion

Deletion is the most complex operation. The goal: remove a key while maintaining all B-Tree properties (minimum occupancy, sorted order, balanced leaves). There are several cases:

Case 1: Key is in a leaf with plenty of keys โ€” just remove it. The node still has at least โŒˆm/2โŒ‰-1 keys, so all properties hold.

Case 2: Key is in a leaf at minimum occupancy โ€” removing it would leave the node underfull. You need to fix this:

Case 3: Key is in an internal node โ€” you can't just remove it (it's a separator between children). Replace it with its in-order predecessor (the largest key in the left child's subtree) or in-order successor (the smallest key in the right child's subtree), both of which are always in a leaf. Then delete that key from the leaf (which reduces to Case 1 or 2).

Interactive Visualization

B-Tree (Order 4)
or click Insert for a random value

B+ Trees โ€” The Database Variant

Most real databases (MySQL/InnoDB, PostgreSQL, SQLite, Oracle, SQL Server) actually use B+ Trees, not plain B-Trees. The B+ Tree is a refinement that optimizes for the access patterns databases care about most: range queries and sequential scans.

Key Differences from B-Trees

Why Databases Prefer B+ Trees

Consider SELECT * FROM users WHERE age BETWEEN 25 AND 35. With a B-Tree, matching records could be scattered across internal and leaf nodes at different levels. You'd need multiple traversals. With a B+ Tree: find the leaf for age=25, then walk the linked list of leaves until age exceeds 35. Sequential, cache-friendly, and fast.

Full table scans are also faster with B+ Trees โ€” just read all the leaves left to right. The internal nodes are never needed for a scan.

๐Ÿ’ก InnoDB's Clustered Index MySQL's InnoDB engine uses a clustered B+ Tree where the leaf nodes store the actual table rows (not just pointers to rows). The primary key determines the physical order of data on disk. This means primary key range queries are insanely fast โ€” the data is physically adjacent. Secondary indexes store a separate B+ Tree where leaves contain the primary key value, requiring a second lookup (called a "bookmark lookup") to get the full row.
๐ŸŽฏ Interview Tip If asked "how does a database index work?", the answer is almost always B+ Trees. Key points: leaf nodes form a linked list for efficient range scans, internal nodes are pure navigation, the tree is always balanced so lookups are O(log n) regardless of data distribution, and the node size is chosen to match the disk block size (typically 4KB-16KB).

Operations & Complexity

OperationTimeDisk ReadsNotes
SearchO(log n)O(logm n)Binary search within each node + follow child
InsertO(log n)O(logm n)May trigger splits cascading up the tree
DeleteO(log n)O(logm n)May trigger merges or rotations cascading up
Range Query (B+)O(log n + k)O(logm n + k/m)k = number of results; sequential leaf scan
Min / MaxO(log n)O(logm n)Follow leftmost/rightmost child at each level
Full Scan (B+)O(n)O(n/m)Walk all leaves via linked list โ€” skip internal nodes

Space: O(n). Tree height: O(logm n) where m is the order. With m=1000 and n=1 billion: height โ‰ˆ 3 levels. With the root cached in RAM, that's 2 disk reads to find any record among a billion.

Implementation

B-Tree: Full Insert + Search (Python)

Python
class BTreeNode:
    """A single node in a B-Tree.
    
    Each node stores:
    - keys: sorted list of keys (at most 2t-1 keys)
    - children: child pointers (at most 2t children for internal nodes)
    - leaf: boolean indicating whether this is a leaf node
    """
    def __init__(self, leaf=True):
        self.keys = []       # Sorted list of keys
        self.children = []   # Child pointers (len = len(keys) + 1 for internal nodes)
        self.leaf = leaf     # Leaf nodes have no children

class BTree:
    """B-Tree of minimum degree t.
    
    Each node holds between t-1 and 2t-1 keys.
    Each internal node has between t and 2t children.
    
    Common degree values:
    - t=2 โ†’ 2-3-4 tree (1-3 keys, 2-4 children) โ€” good for learning
    - t=100 โ†’ typical for databases (199 keys per node)
    - t=500+ โ†’ typical for filesystem metadata
    
    Tree height with t=100 and 1 billion keys: ~5 levels.
    Each level = 1 disk read, so 5 reads to find anything.
    """

    def __init__(self, t=2):
        self.root = BTreeNode()
        self.t = t  # Minimum degree

    def search(self, key, node=None):
        """Search for key in the B-Tree. Returns (node, index) or None.
        
        At each node, we binary search within the keys (O(log t) per node),
        then follow the appropriate child pointer. Total: O(tยทlog_t(n))
        which simplifies to O(log n).
        """
        if node is None:
            node = self.root

        # Find the first key >= target (could use bisect for O(log t))
        i = 0
        while i < len(node.keys) and key > node.keys[i]:
            i += 1

        # Found the key in this node
        if i < len(node.keys) and key == node.keys[i]:
            return (node, i)

        # Hit a leaf without finding the key
        if node.leaf:
            return None

        # Recurse into the appropriate child
        return self.search(key, node.children[i])

    def insert(self, key):
        """Insert a key into the B-Tree.
        
        Strategy: proactively split full nodes on the way DOWN.
        This ensures we never need to split on the way back up,
        simplifying the code significantly.
        """
        root = self.root

        # Special case: if root is full, split it first
        # This is the ONLY way the tree grows taller
        if len(root.keys) == 2 * self.t - 1:
            new_root = BTreeNode(leaf=False)
            new_root.children.append(self.root)
            self._split_child(new_root, 0)
            self.root = new_root

        self._insert_non_full(self.root, key)

    def _insert_non_full(self, node, key):
        """Insert key into a node that is guaranteed to have room.
        
        If the node is a leaf, insert directly.
        If internal, find the right child and recurse.
        Split any full child BEFORE descending into it.
        """
        i = len(node.keys) - 1

        if node.leaf:
            # Insert key in sorted position within this leaf
            node.keys.append(None)  # Extend by one
            while i >= 0 and key < node.keys[i]:
                node.keys[i + 1] = node.keys[i]  # Shift right
                i -= 1
            node.keys[i + 1] = key
        else:
            # Find the child that should receive this key
            while i >= 0 and key < node.keys[i]:
                i -= 1
            i += 1

            # Proactive split: if that child is full, split BEFORE descending
            if len(node.children[i].keys) == 2 * self.t - 1:
                self._split_child(node, i)
                # After split, the median moved up. Check which child to use.
                if key > node.keys[i]:
                    i += 1

            self._insert_non_full(node.children[i], key)

    def _split_child(self, parent, i):
        """Split the i-th child of parent (which must be full).
        
        The full child has 2t-1 keys. After split:
        - Median key moves up to parent at position i
        - Left half stays as original child (t-1 keys)
        - Right half becomes new child at position i+1 (t-1 keys)
        """
        t = self.t
        full_child = parent.children[i]
        new_node = BTreeNode(leaf=full_child.leaf)

        # Median key goes up to parent
        parent.keys.insert(i, full_child.keys[t - 1])
        parent.children.insert(i + 1, new_node)

        # Right half of keys goes to new node
        new_node.keys = full_child.keys[t:]
        full_child.keys = full_child.keys[:t - 1]  # Left half stays

        # If not a leaf, split children too
        if not full_child.leaf:
            new_node.children = full_child.children[t:]
            full_child.children = full_child.children[:t]

    def traverse(self, node=None):
        """In-order traversal โ€” returns all keys in sorted order."""
        if node is None:
            node = self.root
        result = []
        for i in range(len(node.keys)):
            if not node.leaf:
                result.extend(self.traverse(node.children[i]))
            result.append(node.keys[i])
        if not node.leaf:
            result.extend(self.traverse(node.children[-1]))
        return result

    def height(self, node=None):
        """Height of the tree (0 for a single-node tree)."""
        if node is None:
            node = self.root
        if node.leaf:
            return 0
        return 1 + self.height(node.children[0])


# Usage
bt = BTree(t=2)  # 2-3-4 tree (1-3 keys per node)
for val in [10, 20, 5, 6, 12, 30, 7, 17]:
    bt.insert(val)

print("Sorted:", bt.traverse())     # [5, 6, 7, 10, 12, 17, 20, 30]
print("Height:", bt.height())        # Depends on splits
print("Search 12:", bt.search(12))   # (node, index) tuple
print("Search 15:", bt.search(15))   # None

B-Tree: Full Insert + Search (JavaScript)

JavaScript
class BTreeNode {
  constructor(leaf = true) {
    this.keys = [];       // Sorted keys (at most 2t-1)
    this.children = [];   // Child pointers (at most 2t for internal nodes)
    this.leaf = leaf;     // True if this node has no children
  }
}

class BTree {
  /**
   * B-Tree of minimum degree t.
   * - Each non-root node holds at least t-1 keys, at most 2t-1 keys.
   * - Each internal non-root node has at least t children, at most 2t children.
   * - The root may have as few as 1 key (or 0 if the tree is empty).
   * 
   * Common values:
   *   t=2 โ†’ 2-3-4 tree (learning)
   *   t=100 โ†’ databases
   *   t=500+ โ†’ filesystems
   */
  constructor(t = 2) {
    this.root = new BTreeNode();
    this.t = t;
  }

  search(node, key) {
    // Binary search within this node's sorted keys
    let lo = 0, hi = node.keys.length - 1;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      if (node.keys[mid] === key) return { node, index: mid };
      if (node.keys[mid] < key) lo = mid + 1;
      else hi = mid - 1;
    }
    // lo = index of the child to follow (between keys[lo-1] and keys[lo])
    if (node.leaf) return null;
    return this.search(node.children[lo], key);
  }

  insert(key) {
    const root = this.root;

    // If the root is full, split it BEFORE inserting.
    // This is the ONLY way the tree grows taller.
    if (root.keys.length === 2 * this.t - 1) {
      const newRoot = new BTreeNode(false);
      newRoot.children.push(this.root);
      this._splitChild(newRoot, 0);
      this.root = newRoot;
    }

    this._insertNonFull(this.root, key);
  }

  _insertNonFull(node, key) {
    let i = node.keys.length - 1;

    if (node.leaf) {
      // Insert in sorted order within this leaf
      node.keys.push(null); // Make room
      while (i >= 0 && key < node.keys[i]) {
        node.keys[i + 1] = node.keys[i]; // Shift right
        i--;
      }
      node.keys[i + 1] = key;
    } else {
      // Find the correct child to descend into
      while (i >= 0 && key < node.keys[i]) i--;
      i++;

      // Proactive split: if that child is full, split BEFORE descending
      if (node.children[i].keys.length === 2 * this.t - 1) {
        this._splitChild(node, i);
        // After split, median moved up. Decide which half to descend into.
        if (key > node.keys[i]) i++;
      }

      this._insertNonFull(node.children[i], key);
    }
  }

  _splitChild(parent, i) {
    const t = this.t;
    const fullChild = parent.children[i];
    const newNode = new BTreeNode(fullChild.leaf);

    // Median key goes up to parent
    parent.keys.splice(i, 0, fullChild.keys[t - 1]);
    parent.children.splice(i + 1, 0, newNode);

    // Right half of keys โ†’ new node, left half stays
    newNode.keys = fullChild.keys.splice(t);     // keys from index t onward
    fullChild.keys.pop();                          // remove the median (already promoted)

    // If not a leaf, split children too
    if (!fullChild.leaf) {
      newNode.children = fullChild.children.splice(t);
    }
  }

  // In-order traversal โ†’ sorted keys
  traverse(node = this.root) {
    const result = [];
    for (let i = 0; i < node.keys.length; i++) {
      if (!node.leaf) result.push(...this.traverse(node.children[i]));
      result.push(node.keys[i]);
    }
    if (!node.leaf) result.push(...this.traverse(node.children[node.keys.length]));
    return result;
  }

  height(node = this.root) {
    if (node.leaf) return 0;
    return 1 + this.height(node.children[0]);
  }
}

// Usage
const bt = new BTree(2); // 2-3-4 tree
for (const val of [10, 20, 5, 6, 12, 30, 7, 17]) {
  bt.insert(val);
}
console.log("Sorted:", bt.traverse());    // [5, 6, 7, 10, 12, 17, 20, 30]
console.log("Height:", bt.height());
console.log("Search 12:", bt.search(bt.root, 12)); // { node, index }
console.log("Search 15:", bt.search(bt.root, 15)); // null

B-Tree Deletion (Python)

Deletion is the most complex B-Tree operation. Here's a full implementation that handles all three cases: deleting from a leaf with spare keys, deleting from an internal node (replaced by predecessor/successor), and fixing underfull nodes via borrowing or merging.

Python
class BTreeWithDelete(BTree):
    """Extends BTree with full deletion support.
    
    Deletion cases:
    1. Key is in a leaf with > t-1 keys โ†’ just remove it
    2. Key is in an internal node โ†’ replace with predecessor/successor
    3. Key is in a leaf at minimum occupancy โ†’ borrow or merge first
    
    The implementation uses a "proactive fix" approach: ensure every
    node we descend into has at least t keys (one more than minimum),
    so deletion from a leaf never causes underflow.
    """

    def delete(self, key):
        """Delete a key from the B-Tree."""
        if not self.root.keys:
            return  # Empty tree

        self._delete(self.root, key)

        # If root has no keys but has a child, shrink the tree
        # This is the ONLY way the tree gets shorter
        if not self.root.keys and not self.root.leaf:
            self.root = self.root.children[0]

    def _delete(self, node, key):
        t = self.t
        i = 0
        while i < len(node.keys) and key > node.keys[i]:
            i += 1

        if node.leaf:
            # Case 1: Key is in this leaf โ€” remove directly
            if i < len(node.keys) and node.keys[i] == key:
                node.keys.pop(i)
            return

        if i < len(node.keys) and node.keys[i] == key:
            # Case 2: Key is in this internal node
            self._delete_internal(node, i)
        else:
            # Case 3: Key is in a subtree โ€” ensure child has enough keys
            if len(node.children[i].keys) < t:
                self._fix_child(node, i)
                # After fix, recalculate position (merge may have changed it)
                if i > len(node.keys):
                    i -= 1
                # Recheck if key moved to current node after merge
                if i < len(node.keys) and node.keys[i] == key:
                    self._delete_internal(node, i)
                    return
            self._delete(node.children[i], key)

    def _delete_internal(self, node, i):
        """Delete node.keys[i] from an internal node.
        Replace with predecessor or successor from a child."""
        t = self.t

        if len(node.children[i].keys) >= t:
            # Use in-order predecessor (rightmost key in left subtree)
            pred = self._get_predecessor(node.children[i])
            node.keys[i] = pred
            self._delete(node.children[i], pred)
        elif len(node.children[i + 1].keys) >= t:
            # Use in-order successor (leftmost key in right subtree)
            succ = self._get_successor(node.children[i + 1])
            node.keys[i] = succ
            self._delete(node.children[i + 1], succ)
        else:
            # Both children at minimum โ€” merge them
            self._merge(node, i)
            self._delete(node.children[i], node.keys[i] if i < len(node.keys) else node.children[i].keys[t - 1])

    def _get_predecessor(self, node):
        """Get the largest key in this subtree."""
        while not node.leaf:
            node = node.children[-1]
        return node.keys[-1]

    def _get_successor(self, node):
        """Get the smallest key in this subtree."""
        while not node.leaf:
            node = node.children[0]
        return node.keys[0]

    def _fix_child(self, parent, i):
        """Ensure parent.children[i] has at least t keys.
        Try borrowing from a sibling first, then merge."""
        t = self.t

        if i > 0 and len(parent.children[i - 1].keys) >= t:
            # Borrow from left sibling (rotate right)
            child = parent.children[i]
            left = parent.children[i - 1]
            child.keys.insert(0, parent.keys[i - 1])
            parent.keys[i - 1] = left.keys.pop()
            if not left.leaf:
                child.children.insert(0, left.children.pop())

        elif i < len(parent.children) - 1 and len(parent.children[i + 1].keys) >= t:
            # Borrow from right sibling (rotate left)
            child = parent.children[i]
            right = parent.children[i + 1]
            child.keys.append(parent.keys[i])
            parent.keys[i] = right.keys.pop(0)
            if not right.leaf:
                child.children.append(right.children.pop(0))

        else:
            # Neither sibling can spare a key โ€” merge
            if i < len(parent.children) - 1:
                self._merge(parent, i)
            else:
                self._merge(parent, i - 1)

    def _merge(self, parent, i):
        """Merge parent.children[i] and parent.children[i+1].
        Pulls parent.keys[i] down into the merged node."""
        t = self.t
        left = parent.children[i]
        right = parent.children[i + 1]

        # Pull the separating key down from parent
        left.keys.append(parent.keys.pop(i))

        # Move all keys and children from right to left
        left.keys.extend(right.keys)
        left.children.extend(right.children)

        # Remove the right child from parent
        parent.children.pop(i + 1)


# Usage
bt = BTreeWithDelete(t=2)
for val in [10, 20, 5, 6, 12, 30, 7, 17, 3, 8]:
    bt.insert(val)
print("Before:", bt.traverse())  # [3, 5, 6, 7, 8, 10, 12, 17, 20, 30]

bt.delete(6)
bt.delete(20)
bt.delete(10)
print("After:", bt.traverse())   # [3, 5, 7, 8, 12, 17, 30]

Worked Example: Insertion Step by Step

Let's insert the keys [10, 20, 30, 40, 50, 25] into a B-Tree of minimum degree t=2 (a 2-3-4 tree, where each node holds 1-3 keys and has 2-4 children). Walk through every split.

Insert 10: Tree is empty. Root becomes a leaf with key [10].

[10]
    

Insert 20: Root has room (max 3 keys). Insert in sorted order โ†’ [10, 20].

[10, 20]
    

Insert 30: Root has room โ†’ [10, 20, 30]. Now the root is full (3 keys = 2t-1).

[10, 20, 30]  โ† full!
    

Insert 40: Root is full. Must split BEFORE inserting. Median key (20) moves up to a new root. Left child gets [10], right child gets [30]. Then insert 40 into the right child โ†’ [30, 40].

      [20]
     /    \
  [10]   [30, 40]
    

The tree grew taller! This is the only way height increases โ€” the root splits and a new root is created above it.

Insert 50: Search leads to right child [30, 40]. It has room โ†’ [30, 40, 50]. Now that child is full.

        [20]
       /    \
    [10]   [30, 40, 50]  โ† full!
    

Insert 25: Search leads to right child [30, 40, 50], which is full. Proactive split: median (40) moves up to root. Right child becomes [30] and [50]. Now root is [20, 40]. Insert 25 into the child that covers the range 20-40, which is [30]. It becomes [25, 30].

        [20, 40]
       /   |    \
    [10] [25,30] [50]
    

All leaves are at the same depth. Every node respects the min/max key constraints. The tree is perfectly balanced โ€” this invariant never breaks, no matter what sequence of insertions you perform.

Common Mistakes

โš ๏ธ Beginner Pitfalls with B-Trees
  • Confusing B-Tree order conventions: Some books define "order m" as max m children (Knuth). Others define it as max m keys (CLRS uses "minimum degree t" where max keys = 2t-1). Mixing conventions leads to off-by-one bugs in split logic. Always define which convention you're using before implementing.
  • Splitting reactively instead of proactively: The clean implementation splits full children BEFORE descending into them (top-down proactive splitting). The naive approach splits AFTER insertion and propagates upward. Proactive splitting makes the code simpler because you never need to backtrack.
  • Forgetting to split children when they're the ONLY child to be full: When inserting into a tree where only the child you're about to descend into is full, you must split it first โ€” even though the current node has room. The invariant is: never descend into a full node.
  • Not redistributing children during splits: When splitting an internal (non-leaf) node, the children must be redistributed between the two halves. Forgetting this step leaves orphaned children and corrupts the tree structure.
  • Confusing B-Tree and B+ Tree in interviews: When an interviewer says "B-Tree," they often mean B+ Tree (since that's what databases use). Ask for clarification. If discussing database indexes, always describe B+ Tree behavior (data in leaves, leaf linked list).

Real-World Applications

1. Database Indexes (PostgreSQL, MySQL, SQLite)

Every SQL database you've ever used stores its indexes as B+ Trees. When you run CREATE INDEX idx_users_email ON users(email), the database builds a B+ Tree where leaf nodes contain (email โ†’ row_pointer) mappings and internal nodes are pure navigation. A lookup like SELECT * FROM users WHERE email = '[email protected]' traverses 2-3 levels of the tree instead of scanning millions of rows. PostgreSQL's default index type is B-Tree (really B+ Tree). MySQL/InnoDB's clustered index stores actual row data in the B+ Tree leaves, sorted by primary key.

2. Filesystem Metadata (ext4, NTFS, APFS, Btrfs)

Filesystems use B-Trees (or variants) to organize directory entries and file metadata. In ext4 (the default Linux filesystem), large directories use HTree โ€” a B-Tree variant with hashed keys. NTFS uses B+ Trees for its Master File Table ($MFT) indexes. Apple's APFS uses B-Trees for everything: directories, extent maps, snapshot metadata. The Btrfs filesystem ("B-Tree filesystem") is literally named after the data structure โ€” it uses copy-on-write B-Trees for all its metadata, which enables snapshots, checksumming, and online defragmentation.

3. Key-Value Stores (MongoDB, CouchDB, IndexedDB)

MongoDB's default storage engine (WiredTiger) uses B-Trees for its indexes. When you create an index on a MongoDB collection, it's a B-Tree mapping field values to document IDs. Browser-based IndexedDB (used for offline web apps) stores its object stores as B-Trees. Redis Sorted Sets use skip lists (a probabilistic B-Tree analog) for similar reasons โ€” fast ordered operations with logarithmic lookup.

4. Version Control Internal Structures

Git uses a different tree structure (Merkle trees), but many version control and backup systems use B-Trees internally. ZFS (the filesystem) uses B-Trees for its metadata. Time Machine on macOS relies on APFS's B-Tree snapshots to create instant backups without copying data. The SQLite database that powers many applications (including Firefox's bookmarks, Chrome's history, and iOS's system databases) stores everything in B-Trees.

Database Index Simulation

Here's a simplified simulation of how a database uses a B+ Tree index to speed up queries. The index maps column values to row IDs, avoiding full table scans.

Python
class SimpleDBIndex:
    """Simulates a database B+ Tree index for a single column.
    
    Real database indexes (PostgreSQL, MySQL/InnoDB, SQLite) work on
    the same principle: maintain a B+ Tree on the indexed column,
    where leaf nodes store (column_value โ†’ row_id) mappings.
    
    Without an index: SELECT WHERE age=25 scans ALL rows (O(n)).
    With an index: follows the tree to the exact leaf (O(log n)).
    """

    def __init__(self, column_name):
        self.column_name = column_name
        self.btree = BTree(t=3)  # Small t for demonstration
        self.value_to_rows = {}  # value โ†’ set of row IDs

    def index_row(self, value, row_id):
        """Add a row to the index. Called on INSERT or when creating the index."""
        if value not in self.value_to_rows:
            self.btree.insert(value)
            self.value_to_rows[value] = set()
        self.value_to_rows[value].add(row_id)

    def lookup_exact(self, value):
        """Find all rows where column = value. O(log n).
        
        This is what happens when you run:
        SELECT * FROM users WHERE age = 25
        """
        result = self.btree.search(value)
        if result is None:
            return set()
        return self.value_to_rows.get(value, set())

    def lookup_range(self, min_val, max_val):
        """Find all rows where min_val <= column <= max_val.
        
        This is what happens when you run:
        SELECT * FROM users WHERE age BETWEEN 25 AND 35
        
        In a real B+ Tree, this walks the leaf linked list.
        Here we simulate it with the in-order traversal.
        """
        all_keys = self.btree.traverse()
        result = set()
        for key in all_keys:
            if min_val <= key <= max_val:
                result.update(self.value_to_rows.get(key, set()))
            elif key > max_val:
                break  # Keys are sorted, so we can stop early
        return result


class SimpleDatabase:
    """Toy database demonstrating index vs. full scan performance."""

    def __init__(self):
        self.rows = []      # List of row dicts
        self.indexes = {}   # column_name โ†’ SimpleDBIndex

    def insert(self, row):
        row_id = len(self.rows)
        self.rows.append(row)
        # Update all indexes
        for col, index in self.indexes.items():
            if col in row:
                index.index_row(row[col], row_id)
        return row_id

    def create_index(self, column):
        """CREATE INDEX on a column. Indexes all existing rows."""
        index = SimpleDBIndex(column)
        for row_id, row in enumerate(self.rows):
            if column in row:
                index.index_row(row[column], row_id)
        self.indexes[column] = index
        return f"Index created on '{column}' ({len(self.rows)} rows indexed)"

    def query(self, column, value):
        """SELECT * FROM table WHERE column = value."""
        if column in self.indexes:
            # INDEX SCAN: O(log n) via B-Tree
            row_ids = self.indexes[column].lookup_exact(value)
            return [self.rows[rid] for rid in row_ids]
        else:
            # FULL TABLE SCAN: O(n) โ€” checks every row
            return [row for row in self.rows if row.get(column) == value]

    def query_range(self, column, min_val, max_val):
        """SELECT * FROM table WHERE column BETWEEN min AND max."""
        if column in self.indexes:
            row_ids = self.indexes[column].lookup_range(min_val, max_val)
            return [self.rows[rid] for rid in row_ids]
        else:
            return [row for row in self.rows
                    if min_val <= row.get(column, float('inf')) <= max_val]


# Demo
db = SimpleDatabase()

# Insert some users
users = [
    {"name": "Alice", "age": 30, "dept": "Engineering"},
    {"name": "Bob", "age": 25, "dept": "Marketing"},
    {"name": "Charlie", "age": 35, "dept": "Engineering"},
    {"name": "Dave", "age": 28, "dept": "Sales"},
    {"name": "Eve", "age": 30, "dept": "Engineering"},
    {"name": "Frank", "age": 22, "dept": "Marketing"},
]
for user in users:
    db.insert(user)

# Without index: full scan
print("Without index (full scan):")
results = db.query("age", 30)
print(f"  Age=30: {[r['name'] for r in results]}")  # ['Alice', 'Eve']

# Create index on 'age'
print(db.create_index("age"))

# With index: B-Tree lookup
print("\nWith index (B-Tree lookup):")
results = db.query("age", 30)
print(f"  Age=30: {[r['name'] for r in results]}")  # ['Alice', 'Eve']

# Range query
results = db.query_range("age", 25, 30)
print(f"  Age 25-30: {[r['name'] for r in results]}")  # ['Bob', 'Dave', 'Alice', 'Eve']

Interview Patterns

๐ŸŽฏ "Explain database indexing" B+ Trees are the standard answer. Hit these points: (1) all data in leaves, linked list for range scans; (2) internal nodes are pure navigation; (3) tree stays balanced โ†’ O(log n) worst case; (4) node size matches disk block size (4-16KB); (5) root is cached in RAM, so most lookups need only 2-3 disk reads. Mention that without an index, SELECT WHERE requires a full table scan (O(n)), while with an index it's O(log n).
๐ŸŽฏ "B-Tree vs Binary Search Tree" BSTs: O(logโ‚‚ n) height, 1 key per node, 2 children max. Great for in-memory use. B-Trees: O(log_m n) height, many keys per node, many children. Optimized for disk I/O where block reads are cheap but seeks are expensive. A million keys: BST needs ~20 levels (20 disk reads), B-Tree with m=1000 needs ~2 levels (2 disk reads).
๐ŸŽฏ "B-Tree vs B+ Tree" B-Tree stores data in all nodes. B+ Tree stores data ONLY in leaves, with leaves linked together. B+ Tree is better for range queries (just walk the leaf chain) and sequential scans. B-Tree might be slightly better for point lookups if the key happens to be in an internal node (avoiding the full descent to a leaf), but this advantage is marginal and inconsistent.
๐ŸŽฏ "Why is my query slow? Should I add an index?" An index helps when: (1) the column appears in WHERE, JOIN, or ORDER BY clauses; (2) the column has high cardinality (many distinct values โ€” like user IDs, not like gender). An index hurts when: (1) the table is small (full scan is already fast); (2) the column is rarely queried; (3) the table has heavy writes (every INSERT/UPDATE must also update the index). Indexes trade write speed for read speed.
๐ŸŽฏ "What about LSM Trees?" LSM Trees (Log-Structured Merge Trees) are the main alternative to B-Trees for database storage. Used by RocksDB, Cassandra, LevelDB, and HBase. LSM Trees optimize for writes (sequential disk writes instead of random), while B-Trees optimize for reads. In an interview, knowing this comparison shows understanding of storage engine trade-offs. B-Trees: faster reads, slower writes. LSM Trees: faster writes, slower reads (due to compaction overhead).
๐ŸŽฏ Pattern: Composite Indexes A composite index (an index on multiple columns, like (last_name, first_name)) is a B+ Tree where the key is a tuple sorted lexicographically. The index supports queries on the leftmost prefix: (last_name) or (last_name, first_name), but NOT (first_name) alone. This is the leftmost prefix rule โ€” one of the most common gotchas in database performance tuning.

Practice Problems

B-Trees rarely appear directly in coding interviews (they're a system design topic), but these problems test the same concepts โ€” tree balancing, ordered insertion, range queries, and hierarchical search:

ProblemDifficultyKey Idea
LC 701 โ€” Insert into a BST Medium Similar insertion logic โ€” find the right leaf position
LC 450 โ€” Delete Node in a BST Medium Deletion with predecessor/successor replacement
LC 938 โ€” Range Sum of BST Easy Range query on a tree โ€” core B+ Tree operation
LC 230 โ€” Kth Smallest Element in a BST Medium In-order traversal โ€” same concept as B-Tree traversal
LC 1166 โ€” Design File System Medium Tree-structured paths โ€” B-Trees power real filesystem metadata
LC 98 โ€” Validate Binary Search Tree Medium The sorted-keys-within-range invariant applies to B-Trees too
LC 449 โ€” Serialize and Deserialize BST Medium Persistence and reconstruction โ€” how B-Trees are stored on disk
LC 222 โ€” Count Complete Tree Nodes Easy Exploiting balanced-tree structure for efficient counting