← Back to DevBytes

Interview Guide: B+ Trees Problems and Solutions

Understanding B+ Trees

A B+ Tree is a self-balancing tree data structure that maintains sorted data and allows for efficient insertion, deletion, and search operations. It is an extension of the B-Tree where all values are stored only at the leaf nodes, and internal nodes contain only keys used as routers to guide the search down the tree. The leaf nodes are linked together in a sequential linked list, enabling fast range queries and ordered traversal.

The key structural difference between a B-Tree and a B+ Tree is that in a B+ Tree, data pointers exist exclusively at the leaf level. Internal nodes carry index keys and child pointers but no actual data records. This design makes B+ Trees the data structure of choice for database indexing systems, including MySQL's InnoDB, PostgreSQL, and many file systems.

Core Properties of B+ Trees

Why B+ Trees Matter in Interviews

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

B+ Trees appear frequently in system design and data structure interviews, especially for roles involving database internals, storage engines, or file systems. Interviewers test your understanding of how real-world indexing works, your ability to implement tree operations, and your grasp of time/space complexity tradeoffs. Key areas include:

Common Interview Scenarios

You might be asked to implement a simplified in-memory B+ Tree, explain how range queries work, handle node splits and merges, or discuss how B+ Trees compare to other indexing structures like LSM Trees or Hash Indexes. Understanding the mechanics of insertion and deletion with proper redistribution is essential.

Implementing a B+ Tree: Complete Example

Below is a complete implementation of an in-memory B+ Tree in Python. This example covers insertion, search, range queries, node splitting, and tree printing. It's designed to be interview-ready — clean, well-commented, and handling edge cases.

from collections import deque
import math

class BPlusTreeNode:
    """Represents an internal node or a leaf node in the B+ Tree."""
    def __init__(self, order, is_leaf=False):
        self.order = order          # Maximum number of keys (and children for internal nodes)
        self.is_leaf = is_leaf
        self.keys = []              # Sorted keys
        self.children = []          # Child pointers (only for internal nodes)
        self.values = []            # Data values (only for leaf nodes)
        self.next_leaf = None       # Pointer to next leaf (for range queries)

class BPlusTree:
    def __init__(self, order):
        """
        Initialize B+ Tree with a given order.
        Order defines max keys per node: order-1 for internal, order for leaves.
        Typical order values are large (e.g., 100+) for disk-based systems,
        but we use small values for demonstration.
        """
        if order < 3:
            raise ValueError("Order must be at least 3")
        self.order = order
        self.root = BPlusTreeNode(order, is_leaf=True)
        self.max_keys_internal = order - 1
        self.min_keys_internal = math.ceil(order / 2) - 1
        self.max_keys_leaf = order
        self.min_keys_leaf = math.ceil(order / 2)

    def search(self, key):
        """Search for a key. Returns (value, leaf_node) or (None, None) if not found."""
        node = self.root
        while not node.is_leaf:
            # Find child to descend into
            pos = 0
            while pos < len(node.keys) and key >= node.keys[pos]:
                pos += 1
            node = node.children[pos]
        
        # Now at leaf node
        for i, k in enumerate(node.keys):
            if k == key:
                return node.values[i], node
        return None, None

    def range_search(self, start_key, end_key):
        """Perform a range query: return all (key, value) pairs with start_key <= key <= end_key."""
        results = []
        
        # Find the first leaf that could contain keys >= start_key
        node = self.root
        while not node.is_leaf:
            pos = 0
            while pos < len(node.keys) and start_key >= node.keys[pos]:
                pos += 1
            node = node.children[pos]
        
        # Traverse linked leaves
        while node is not None:
            for i, k in enumerate(node.keys):
                if k > end_key:
                    return results
                if k >= start_key:
                    results.append((k, node.values[i]))
            node = node.next_leaf
        
        return results

    def insert(self, key, value):
        """Insert a key-value pair into the B+ Tree."""
        root = self.root
        
        # If root is full, split it first
        if len(root.keys) >= self.max_keys_leaf if root.is_leaf else len(root.keys) >= self.max_keys_internal:
            new_root = BPlusTreeNode(self.order, is_leaf=False)
            new_root.children.append(self.root)
            self.root = new_root
            self._split_child(new_root, 0)
        
        self._insert_non_full(self.root, key, value)

    def _insert_non_full(self, node, key, value):
        """Insert into a node that is guaranteed to have room."""
        if node.is_leaf:
            # Insert into leaf maintaining sorted order
            pos = 0
            while pos < len(node.keys) and node.keys[pos] < key:
                pos += 1
            if pos < len(node.keys) and node.keys[pos] == key:
                # Key exists, update value
                node.values[pos] = value
                return
            node.keys.insert(pos, key)
            node.values.insert(pos, value)
        else:
            # Internal node: find child to insert into
            pos = 0
            while pos < len(node.keys) and key >= node.keys[pos]:
                pos += 1
            
            child = node.children[pos]
            # Check if child is full
            if len(child.keys) >= (self.max_keys_leaf if child.is_leaf else self.max_keys_internal):
                self._split_child(node, pos)
                # After split, determine which child to follow
                if key > node.keys[pos]:
                    pos += 1
            
            self._insert_non_full(node.children[pos], key, value)

    def _split_child(self, parent, child_index):
        """Split a full child node (leaf or internal) of parent at child_index."""
        child = parent.children[child_index]
        mid_index = len(child.keys) // 2
        
        if child.is_leaf:
            # Splitting a leaf node
            new_node = BPlusTreeNode(self.order, is_leaf=True)
            
            # Move keys and values to new node
            new_node.keys = child.keys[mid_index:]
            new_node.values = child.values[mid_index:]
            child.keys = child.keys[:mid_index]
            child.values = child.values[:mid_index]
            
            # Adjust leaf linked list pointers
            new_node.next_leaf = child.next_leaf
            child.next_leaf = new_node
            
            # The key to promote is the first key of the new leaf
            promote_key = new_node.keys[0]
        else:
            # Splitting an internal node
            new_node = BPlusTreeNode(self.order, is_leaf=False)
            promote_key = child.keys[mid_index]
            
            # Move keys and children to new node
            new_node.keys = child.keys[mid_index + 1:]
            new_node.children = child.children[mid_index + 1:]
            child.keys = child.keys[:mid_index]
            child.children = child.children[:mid_index + 1]
        
        # Insert the new node and promote key into parent
        parent.keys.insert(child_index, promote_key)
        parent.children.insert(child_index + 1, new_node)

    def delete(self, key):
        """Delete a key from the B+ Tree. Returns True if deleted, False if not found."""
        if self.root.is_leaf and len(self.root.keys) == 0:
            return False
        
        result = self._delete(self.root, key)
        
        # If root has no keys after deletion, shrink tree height
        if not self.root.is_leaf and len(self.root.keys) == 0:
            self.root = self.root.children[0]
        
        return result

    def _delete(self, node, key):
        """Recursive helper for deletion."""
        if node.is_leaf:
            # Search for key in leaf
            for i, k in enumerate(node.keys):
                if k == key:
                    node.keys.pop(i)
                    node.values.pop(i)
                    return True
            return False
        
        # Internal node
        pos = 0
        while pos < len(node.keys) and key >= node.keys[pos]:
            pos += 1
        
        child = node.children[pos]
        deleted = self._delete(child, key)
        
        # If child underflows, handle redistribution or merge
        if child.is_leaf:
            min_keys = self.min_keys_leaf
            max_keys = self.max_keys_leaf
        else:
            min_keys = self.min_keys_internal
            max_keys = self.max_keys_internal
        
        if len(child.keys) < min_keys:
            self._handle_underflow(node, pos, child.is_leaf)
        
        return deleted

    def _handle_underflow(self, parent, child_index, is_leaf):
        """Handle underflow by redistributing or merging nodes."""
        child = parent.children[child_index]
        min_keys = self.min_keys_leaf if is_leaf else self.min_keys_internal
        
        # Try borrowing from left sibling
        if child_index > 0:
            left_sibling = parent.children[child_index - 1]
            if len(left_sibling.keys) > min_keys:
                self._borrow_from_left(parent, child_index, is_leaf)
                return
        
        # Try borrowing from right sibling
        if child_index < len(parent.children) - 1:
            right_sibling = parent.children[child_index + 1]
            if len(right_sibling.keys) > min_keys:
                self._borrow_from_right(parent, child_index, is_leaf)
                return
        
        # Merge with a sibling
        if child_index > 0:
            self._merge_nodes(parent, child_index - 1, child_index, is_leaf)
        else:
            self._merge_nodes(parent, child_index, child_index + 1, is_leaf)

    def _borrow_from_left(self, parent, child_index, is_leaf):
        """Redistribute: borrow one key from left sibling."""
        child = parent.children[child_index]
        left_sibling = parent.children[child_index - 1]
        
        if is_leaf:
            # Move last key-value from left sibling to child
            borrowed_key = left_sibling.keys.pop(-1)
            borrowed_val = left_sibling.values.pop(-1)
            child.keys.insert(0, borrowed_key)
            child.values.insert(0, borrowed_val)
            # Update parent separator key
            parent.keys[child_index - 1] = child.keys[0]
        else:
            # Move separator key down to child, move child from left sibling
            child.keys.insert(0, parent.keys[child_index - 1])
            child.children.insert(0, left_sibling.children.pop(-1))
            parent.keys[child_index - 1] = left_sibling.keys.pop(-1)

    def _borrow_from_right(self, parent, child_index, is_leaf):
        """Redistribute: borrow one key from right sibling."""
        child = parent.children[child_index]
        right_sibling = parent.children[child_index + 1]
        
        if is_leaf:
            borrowed_key = right_sibling.keys.pop(0)
            borrowed_val = right_sibling.values.pop(0)
            child.keys.append(borrowed_key)
            child.values.append(borrowed_val)
            parent.keys[child_index] = right_sibling.keys[0]
        else:
            child.keys.append(parent.keys[child_index])
            child.children.append(right_sibling.children.pop(0))
            parent.keys[child_index] = right_sibling.keys.pop(0)

    def _merge_nodes(self, parent, left_idx, right_idx, is_leaf):
        """Merge two sibling nodes into one."""
        left_node = parent.children[left_idx]
        right_node = parent.children[right_idx]
        
        if is_leaf:
            left_node.keys.extend(right_node.keys)
            left_node.values.extend(right_node.values)
            left_node.next_leaf = right_node.next_leaf
        else:
            # Pull down separator key and merge
            left_node.keys.append(parent.keys[left_idx])
            left_node.keys.extend(right_node.keys)
            left_node.children.extend(right_node.children)
        
        # Remove right node and separator from parent
        parent.keys.pop(left_idx)
        parent.children.pop(right_idx)

    def print_tree(self):
        """Print the tree structure level by level."""
        if not self.root.keys and self.root.is_leaf:
            print("Empty tree")
            return
        
        queue = deque([(self.root, 0)])
        current_level = 0
        level_output = []
        
        while queue:
            node, level = queue.popleft()
            
            if level != current_level:
                print(f"Level {current_level}: {' | '.join(level_output)}")
                level_output = []
                current_level = level
            
            if node.is_leaf:
                level_output.append(f"Leaf[{','.join(map(str, node.keys))}]")
            else:
                level_output.append(f"Int[{','.join(map(str, node.keys))}]")
            
            if not node.is_leaf:
                for child in node.children:
                    queue.append((child, level + 1))
        
        if level_output:
            print(f"Level {current_level}: {' | '.join(level_output)}")

Using the B+ Tree Implementation

Here's how to use the tree in practice, demonstrating insertion, search, range queries, and deletion:

# Create a B+ Tree with order 4 (max 4 keys per leaf, max 3 keys per internal node)
tree = BPlusTree(order=4)

# Insert key-value pairs
data = [(5, "apple"), (12, "banana"), (8, "cherry"), (3, "date"), 
        (20, "elderberry"), (15, "fig"), (1, "grape"), (7, "honeydew"),
        (25, "iceberg"), (18, "jackfruit")]

for key, value in data:
    tree.insert(key, value)
    print(f"Inserted ({key}, {value})")

# Print tree structure
print("\nTree structure after insertions:")
tree.print_tree()

# Search for keys
key_to_find = 12
result, _ = tree.search(key_to_find)
print(f"\nSearch for {key_to_find}: {result}")

# Range query
print("\nRange query [5, 18]:")
range_results = tree.range_search(5, 18)
for k, v in range_results:
    print(f"  ({k}, {v})")

# Delete a key
tree.delete(8)
print("\nAfter deleting key 8:")
tree.print_tree()

Common Interview Problems

Problem 1: Design a Database Index Using B+ Trees

Scenario: You're asked to design an indexing system for a database table with millions of rows. The table has a primary key and several secondary indexes. Explain how B+ Trees would be used for both clustered and non-clustered indexes.

Solution Approach:

# Simplified illustration of how a database might use B+ Trees for indexing
class DatabaseIndex:
    def __init__(self, order=128):
        self.clustered_index = BPlusTree(order)      # Primary key index
        self.secondary_indexes = {}                   # Name -> BPlusTree
    
    def create_secondary_index(self, column_name):
        self.secondary_indexes[column_name] = BPlusTree(order=128)
    
    def insert_row(self, primary_key, row_data):
        # Store row in clustered index
        self.clustered_index.insert(primary_key, row_data)
        
        # Update secondary indexes
        for col_name, index in self.secondary_indexes.items():
            secondary_key = row_data.get(col_name)
            if secondary_key is not None:
                index.insert(secondary_key, primary_key)
    
    def range_query_by_primary(self, start_pk, end_pk):
        return self.clustered_index.range_search(start_pk, end_pk)
    
    def lookup_by_secondary(self, col_name, key):
        # Returns primary keys, then lookup clustered index
        index = self.secondary_indexes.get(col_name)
        if not index:
            return []
        pk_result, _ = index.search(key)
        if pk_result:
            row, _ = self.clustered_index.search(pk_result)
            return row
        return None

Problem 2: Implement Range Query Optimization

Scenario: Given a B+ Tree with millions of records, implement an efficient range count operation that returns the number of keys in a given range without retrieving all values.

Solution: Augment each internal node with subtree key counts. This transforms the B+ Tree into an order statistic tree, enabling O(log n) range counting.

class AugmentedBPlusTree(BPlusTree):
    """
    Augmented B+ Tree that maintains subtree key counts in internal nodes
    for O(log n) range counting.
    """
    def __init__(self, order):
        super().__init__(order)
        self.root.subtree_count = 0
    
    def insert(self, key, value):
        super().insert(key, value)
        self._update_counts(self.root)
    
    def _update_counts(self, node):
        if node.is_leaf:
            node.subtree_count = len(node.keys)
        else:
            count = 0
            for child in node.children:
                self._update_counts(child)
                count += child.subtree_count
            node.subtree_count = count
    
    def count_keys_in_range(self, start_key, end_key):
        """Count keys between start_key and end_key inclusive."""
        return self._count_range(self.root, start_key, end_key)
    
    def _count_range(self, node, start_key, end_key):
        if node is None:
            return 0
        
        if node.is_leaf:
            count = 0
            for k in node.keys:
                if start_key <= k <= end_key:
                    count += 1
            return count
        
        # Internal node
        total = 0
        for i, key in enumerate(node.keys):
            child = node.children[i]
            if i == 0 or node.keys[i-1] < end_key:
                total += self._count_range(child, start_key, end_key)
            
            if start_key <= key <= end_key:
                total += 1
        
        # Last child (beyond all keys)
        last_child = node.children[-1]
        total += self._count_range(last_child, start_key, end_key)
        
        return total

# Usage
aug_tree = AugmentedBPlusTree(order=8)
for i in range(1, 1001):
    aug_tree.insert(i, f"value_{i}")

count = aug_tree.count_keys_in_range(250, 750)
print(f"Keys in range [250, 750]: {count}")  # Should output 501

Problem 3: Handle Concurrent Access with Latch Coupling

Scenario: In a multi-threaded database, multiple transactions read and write to the B+ Tree concurrently. How do you ensure thread safety while maintaining high throughput?

Solution: Use latch coupling (also called lock coupling) — a technique where you acquire a latch on a child node before releasing the latch on the parent. This prevents tree restructuring from affecting concurrent readers.

import threading

class ThreadSafeBPlusTree(BPlusTree):
    """
    B+ Tree with per-node latches for concurrent access.
    Uses latch coupling for safe traversal during structural changes.
    """
    def __init__(self, order):
        super().__init__(order)
        self.root_latch = threading.RWLock()  # Reader-writer lock for root
        self.node_latches = {}                # Node id -> RWLock
    
    def _get_latch(self, node):
        node_id = id(node)
        if node_id not in self.node_latches:
            self.node_latches[node_id] = threading.RWLock()
        return self.node_latches[node_id]
    
    def search_threadsafe(self, key):
        """Thread-safe search using latch coupling."""
        parent_latch = None
        node = self.root
        
        while not node.is_leaf:
            # Acquire child latch before releasing parent (latch coupling)
            pos = 0
            while pos < len(node.keys) and key >= node.keys[pos]:
                pos += 1
            
            child = node.children[pos]
            child_latch = self._get_latch(child)
            child_latch.acquire_read()
            
            # Now release parent latch if we held one
            if parent_latch:
                parent_latch.release_read()
            
            parent_latch = child_latch
            node = child
        
        # At leaf, search for key
        try:
            for i, k in enumerate(node.keys):
                if k == key:
                    return node.values[i]
            return None
        finally:
            if parent_latch:
                parent_latch.release_read()
    
    def insert_threadsafe(self, key, value):
        """
        Thread-safe insert. Uses latch coupling down the tree,
        and upgrades to write latches when splits might occur.
        """
        # Simplified: acquire write latch on root, then traverse
        # A full implementation would use optimistic latch coupling
        root_latch = self.root_latch
        root_latch.acquire_write()
        try:
            self.insert(key, value)
        finally:
            root_latch.release_write()

Problem 4: Bulk Loading a B+ Tree

Scenario: You have a sorted dataset of 10 million records. Building the tree one insert at a time is O(n log n) and causes many splits. How can you build it more efficiently?

Solution: Bulk loading builds the tree bottom-up in O(n) time by first sorting the data, grouping it into leaf nodes, then building internal levels layer by layer.

def bulk_load_bplus_tree(sorted_data, order):
    """
    Bulk load a B+ Tree from sorted data.
    sorted_data: list of (key, value) tuples sorted by key.
    order: maximum keys per leaf node.
    Returns: BPlusTree instance.
    """
    if not sorted_data:
        return BPlusTree(order)
    
    # Step 1: Create leaf nodes
    leaves = []
    for i in range(0, len(sorted_data), order):
        chunk = sorted_data[i:i + order]
        leaf = BPlusTreeNode(order, is_leaf=True)
        leaf.keys = [k for k, v in chunk]
        leaf.values = [v for k, v in chunk]
        leaves.append(leaf)
    
    # Step 2: Link leaves
    for i in range(len(leaves) - 1):
        leaves[i].next_leaf = leaves[i + 1]
    
    # Step 3: Build internal levels bottom-up
    current_level = leaves
    max_internal = order - 1
    
    while len(current_level) > 1:
        next_level = []
        for i in range(0, len(current_level), max_internal + 1):
            chunk = current_level[i:i + max_internal + 1]
            internal_node = BPlusTreeNode(order, is_leaf=False)
            
            # Keys are the first key of each leaf except the first child's leaf
            for j in range(1, len(chunk)):
                internal_node.keys.append(chunk[j].keys[0])
            
            internal_node.children = chunk
            next_level.append(internal_node)
        current_level = next_level
    
    # Root is the single node at top level
    tree = BPlusTree(order)
    tree.root = current_level[0]
    return tree

# Example usage
sorted_data = [(i, f"record_{i}") for i in range(1, 101)]
tree = bulk_load_bplus_tree(sorted_data, order=6)
print("Bulk-loaded tree:")
tree.print_tree()

Best Practices for B+ Tree Interviews

1. Master the Fundamentals First

Before diving into optimizations, ensure you can explain the basic structure: what distinguishes a B+ Tree from a B-Tree, why leaf nodes are linked, and what "order" means. Practice drawing the tree on a whiteboard for small order values (like order=3 or 4) to visualize splits and merges.

2. Know Your Complexity Numbers

3. Handle Edge Cases Explicitly

Interviewers watch for edge case handling. Key scenarios to cover:

4. Connect to Real-World Systems

Mention how B+ Trees are used in practice: MySQL InnoDB uses them for clustered indexes with a default page size of 16KB and typical order around 100-200. PostgreSQL uses B+ Trees (actually a variant called B-Link Trees) with similar page structures. File systems like NTFS and HFS+ use B+ Trees for directory indexing.

5. Discuss Variants and Tradeoffs

Show depth by comparing B+ Trees to alternatives:

6. Optimize for the Context

In memory-constrained environments, consider node cache policies. For disk-based systems, align node sizes to page boundaries (e.g., 4KB or 16KB). For SSDs, random reads are cheaper, but still aim to minimize them through high fanout. In write-heavy systems, consider delaying merges or using a background rebalancer.

Conclusion

B+ Trees remain one of the most important data structures in systems engineering, and they appear consistently in technical interviews for backend, infrastructure, and database roles. The combination of logarithmic search time, efficient range queries via linked leaves, and natural alignment with disk page structures makes them indispensable. By understanding the core operations — insertion with splits, deletion with redistribution and merging, and range scanning — and being able to implement them cleanly, you demonstrate both algorithmic fluency and systems thinking. The code examples in this guide provide a complete foundation; practice modifying them to handle different order values, concurrent access patterns, and bulk loading scenarios. When you can confidently explain why a B+ Tree outperforms a binary search tree for database indexing and walk through a split operation step by step, you'll be well-prepared for any B+ Tree interview question.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles