πŸ“š Study Notes / Home / DBMS / Session 6
Session 06 Β· Index Structures β€” B+ Trees

How databases find a row without reading the whole table

Imagine a phone book with a million names and no alphabetical order. Finding "Zara" would mean reading every page. Databases face this exact problem β€” and the clever answer is an index, most often a B+ tree. We assume you've studied none of this. Every topic starts with a tiny everyday story, then we build up the real structure, the search, and the inserts β€” slowly, with pictures and pseudocode.

⏱ 26 min readπŸ“– 4 topics

1 Why indexes β€” the full-scan problem


Explain like I'm 5

Picture a giant book with no contents page. To find the one page that mentions dinosaurs, you'd have to flip through every single page from the start. Exhausting! Now imagine someone added an index at the back: "Dinosaurs … page 184." You jump straight there. A database index is exactly that back-of-the-book index β€” a sorted shortcut that tells the database where to look, so it doesn't have to read everything.

A database table is stored as rows packed into fixed-size blocks on disk called pages (recall the buffer pool from Session 5 β€” pages are the unit the database reads from disk into memory). When you ask a question like SELECT * FROM students WHERE id = 8472, the database has two options.

Option A β€” the full table scan

A full table scan means reading every page of the table, row by row, checking each one to see if it matches. If the table has a million rows spread across thousands of pages, that's thousands of disk reads β€” even though you only wanted one row. Disk reads are slow (orders of magnitude slower than memory), so this is the expensive thing we want to avoid.

Concrete example

A table of 1,000,000 students, 100 rows per page β†’ 10,000 pages. To find one student by id with no index, the database reads up to all 10,000 pages. With a good index, it reads about 3–4 pages instead. That's the difference between a query that takes seconds and one that takes milliseconds.

Option B β€” use an index (the sorted shortcut)

An index is a separate, sorted data structure built on one or more columns (the search key). Because it's kept in sorted order, the database can use fast lookup techniques β€” like binary search, or walking down a tree β€” to zoom in on the matching rows without scanning the whole table.

The one big idea

An index trades a little extra storage and slightly slower writes for dramatically faster reads. Instead of "look at everything," the database can say "the sorted index tells me exactly where row 8472 lives β€” fetch just that page."

Index vs. the table itself

It's important to keep these two things separate in your head:

AspectThe table (heap)The index
What it storesAll the actual row data.Just the search-key values, plus a pointer to where the full row lives.
OrderUsually unordered ("heap") β€” rows sit wherever there was room.Always kept sorted by the search key.
SizeBig β€” holds everything.Smaller β€” only key + pointer per row.
PurposeBe the source of truth.Be a fast lookup map into the table.

The pointer in an index is typically a record ID (often written RID): the page number plus the slot on that page where the full row sits. Find the key in the index, follow the pointer, fetch one page β€” done.

The trade-off (there's no free lunch)

Indexes make reads fast, but they cost you:

  • Slower writes. Every INSERT, UPDATE, or DELETE must also update every index on that column to keep it sorted. More indexes = more write work.
  • Extra space. Each index is a whole separate structure stored on disk.

So you index the columns you search and join on a lot β€” not every column. Choosing indexes wisely is a core database-tuning skill.

Many kinds of index exist

Hash indexes, bitmap indexes, and tree indexes are all options. By far the most common default in real databases (PostgreSQL, MySQL/InnoDB, SQLite, Oracle…) is the B+ tree β€” because it's great at both single-value lookups and range queries, and it stays fast even when it's huge. That's our whole focus from here on.

Recap Without an index, finding a row means a slow full table scan of every page. An index is a separate sorted structure of (key β†’ pointer) that lets the database jump straight to the right page. The price is slower writes and extra disk space β€” so you index the columns you query most.

2 B+ tree structure & invariants


Explain like I'm 5

Think of a company org chart, but upside down. At the top is one boss (the root). The boss doesn't do the work β€” they just point you to the right manager. That manager points you to the right team lead, and finally you reach the workers (the leaves) who actually hold the answers. To find anything, you only ask a few people on the way down instead of bothering everyone. A B+ tree is that pointing-you-down structure, kept perfectly balanced so the trip is always short.

A B+ tree is a balanced, sorted tree designed specifically for disk. Each node is exactly one page, so reading a node = one disk read. There are two kinds of nodes.

Two node types: internal vs. leaf

Node typeWhat it holdsJob
Internal node (incl. the root)Sorted separator keys and child pointers only β€” no data.Act as a signpost: "keys below X go left, keys β‰₯ X go right."
Leaf nodeThe actual sorted keys, each with its data pointer (RID), plus a pointer to the next leaf.Hold the real answers and chain together for range scans.
The defining feature of a B+ tree

In a B+ tree, all the real data lives only in the leaves. Internal nodes are pure signposts. (This is the key difference from a plain B-tree, where data can sit in internal nodes too.) Putting all data at the bottom keeps internal nodes tiny, so each one can point to many children β€” which makes the tree very shallow.

Order / fanout β€” why the tree is so shallow

The fanout (also called the order) is the maximum number of children an internal node can have. Because each node is one page (say 4 KB or 8 KB) and a key+pointer pair is small (maybe ~16 bytes), a single node can hold hundreds of pointers. A fanout of a few hundred is normal.

High fanout is the whole magic. A tree where each node has ~hundreds of children gets very tall capacity from very few levels:

Example: how few levels you need

With a fanout of 100:

  • 1 level (just leaves) β†’ ~100 keys
  • 2 levels β†’ ~100 Γ— 100 = 10,000 keys
  • 3 levels β†’ ~100 Γ— 100 Γ— 100 = 1,000,000 keys
  • 4 levels β†’ ~100,000,000 keys

So a B+ tree holding 100 million rows is only 4 levels deep. Finding any row touches just 4 nodes = 4 disk reads (and the top levels are usually already cached in the buffer pool, so it's often fewer). That's why databases love B+ trees.

A picture of a small B+ tree

Here's a tiny B+ tree (fanout small, just for drawing). Notice the leaves are chained left to right with arrows β€” that's the linked list.

                          [ internal / root ]
                          +-------------------+
                          |   13   |   30   |
                          +--+-----+------+---+
                  <13       |     | 13..29|      |  >=30
            +---------------+     +---+   +------------------+
            v                         v                      v
      [ leaf ]                  [ leaf ]                [ leaf ]
   +----+----+----+         +----+----+----+        +----+----+----+
   |  5 |  9 | 11 |  -->   | 13 | 20 | 27 |  -->  | 30 | 41 | 55 |  --> (null)
   +----+----+----+         +----+----+----+        +----+----+----+
     each key also carries a pointer (RID) to its full row in the table
        

Read the root as a set of signposts: keys < 13 go to the first child, keys 13 to 29 go to the middle child, keys β‰₯ 30 go to the last child. Notice 13 appears both in the root (as a separator) and in a leaf (as real data) β€” that's normal in a B+ tree, because the root copy is just a signpost.

The invariants (rules the tree always keeps)

A B+ tree is always valid because it enforces these rules on every operation:

  • Sorted. Keys within every node are in ascending order, and leaves overall read left-to-right as one fully sorted sequence.
  • Balanced. Every leaf is at the exact same depth. The tree never gets lopsided, so every lookup costs the same number of steps. This is the balance invariant.
  • Half-full (the occupancy rule). Every node except the root must be at least half full (roughly between ⌈order/2βŒ‰ and order entries). This guarantees the tree doesn't waste space or grow needlessly tall.
  • Leaf chain. Leaves form a linked list: each leaf points to the next leaf in sorted order. This is what makes range scans fast (Topic 3).
  • Height stays low. Because of balance + high fanout, height is O(logfanout n) β€” a small number even for billions of rows.
Why "balanced" matters so much

An ordinary binary search tree can degenerate into a long thin chain (basically a linked list) if you insert sorted data, making lookups slow again. A B+ tree guarantees balance by splitting nodes as it grows (Topic 4), so it can never degenerate. Performance is predictable no matter what order you insert keys.

Recap A B+ tree has internal nodes (sorted separator keys + child pointers, pure signposts) and leaf nodes (the real sorted keys + data pointers, chained into a linked list). High fanout packs hundreds of pointers per page, so even a billion rows fit in ~4 levels. It always stays sorted, balanced, and at least half full, giving O(log n) lookups with very few disk reads.

4 Insertion & page splits


Explain like I'm 5

Imagine a school bus with rows of seats. New kids keep getting on. When a row is full and one more kid needs to sit there, you split the row into two and tell the bus monitor (one level up) "there's a new row now, here's where it starts." If the monitor's clipboard is also full, they split too, and tell their boss. Occasionally even the head teacher's list overflows and a brand-new top boss is created β€” that's the bus (the tree) growing one level taller. B+ tree insertion is exactly this splitting-and-telling-upward.

Inserting a key must keep all the invariants true (sorted, balanced, half-full). The trick that preserves balance is the page split.

The algorithm in words

  • Find the leaf where the key belongs (same downward search as a lookup).
  • If the leaf has room, insert the key in sorted position. Done β€” easy case.
  • If the leaf is full, split it into two leaves, dividing the keys roughly in half. Then copy up the smallest key of the new right leaf into the parent as a new separator, and fix the leaf-chain pointers.
  • If the parent is now full too, split the parent the same way β€” but for an internal node you push up the middle key (it moves up, it isn't copied), since internal nodes only hold signposts.
  • If the split reaches the root and the root splits, a brand-new root is created above it. This is the only way a B+ tree grows taller β€” from the top, which is why all leaves always stay at the same depth.
"Copy up" vs "push up" β€” the one subtlety

When a leaf splits, the separator key still needs to physically remain in the leaf (it's real data), so a copy of it goes up. When an internal node splits, the middle key is only a signpost, so it moves up entirely (push up) β€” it should not be duplicated. Mixing these up is the classic B+ tree bug.

Here is a correct, self-contained insertion for a B+ tree of a given order (max keys per node). It descends recursively; when a child overflows it returns a separator key and a new right sibling, which the parent absorbs. Leaves copy up; internal nodes push up. Each tab ends with a tiny demo that inserts a few keys and searches one.

# B+ tree insertion with leaf split + key promotion.
# Reuses the Node class from the search example above.

ORDER = 3                       # max keys per node; split when a 4th arrives

class BPlusTree:
    def __init__(self):
        self.root = Node(leaf=True)

    def insert(self, key, rid):
        sep, right = self._insert(self.root, key, rid)
        if right is not None:        # root split -> grow a new root (+1 level)
            new_root = Node(leaf=False)
            new_root.keys = [sep]
            new_root.children = [self.root, right]
            self.root = new_root

    # Returns (separator, right_sibling) if node split, else (None, None).
    def _insert(self, node, key, rid):
        if node.leaf:
            i = 0
            while i < len(node.keys) and node.keys[i] < key:
                i += 1
            node.keys.insert(i, key)
            node.rids.insert(i, rid)
        else:
            i = 0
            while i < len(node.keys) and key >= node.keys[i]:
                i += 1
            sep, right = self._insert(node.children[i], key, rid)
            if right is None:
                return None, None
            node.keys.insert(i, sep)             # absorb child's separator
            node.children.insert(i + 1, right)

        if len(node.keys) <= ORDER:
            return None, None               # no overflow
        return self._split(node)

    def _split(self, node):
        mid = len(node.keys) // 2
        right = Node(leaf=node.leaf)
        if node.leaf:
            # COPY up: separator stays in the right leaf as real data.
            right.keys = node.keys[mid:]
            right.rids = node.rids[mid:]
            node.keys = node.keys[:mid]
            node.rids = node.rids[:mid]
            right.next = node.next               # fix leaf chain
            node.next = right
            sep = right.keys[0]
        else:
            # PUSH up: middle key moves up, leaving this node.
            sep = node.keys[mid]
            right.keys = node.keys[mid + 1:]
            right.children = node.children[mid + 1:]
            node.keys = node.keys[:mid]
            node.children = node.children[:mid + 1]
        return sep, right


# --- demo ---
t = BPlusTree()
for k in [10, 20, 30, 40, 50, 25]:
    t.insert(k, rid=k * 100)         # fake RID = key*100
print(search(t.root, 25))           # -> 2500
print(search(t.root, 99))           # -> None
// B+ tree insertion with leaf split + key promotion.
// Reuses the Node struct from the search example above.
#include <vector>
#include <iostream>

const int ORDER = 3;        // max keys per node

struct Split { int sep; Node* right; };   // right == nullptr means "no split"

struct BPlusTree {
    Node* root = new Node(true);

    void insert(int key, int rid) {
        Split s = insertRec(root, key, rid);
        if (s.right) {                       // root split -> grow a new root
            Node* nr = new Node(false);
            nr->keys.push_back(s.sep);
            nr->children = { root, s.right };
            root = nr;
        }
    }

    Split insertRec(Node* node, int key, int rid) {
        if (node->leaf) {
            size_t i = 0;
            while (i < node->keys.size() && node->keys[i] < key) ++i;
            node->keys.insert(node->keys.begin() + i, key);
            node->rids.insert(node->rids.begin() + i, rid);
        } else {
            size_t i = 0;
            while (i < node->keys.size() && key >= node->keys[i]) ++i;
            Split s = insertRec(node->children[i], key, rid);
            if (!s.right) return { 0, nullptr };
            node->keys.insert(node->keys.begin() + i, s.sep);
            node->children.insert(node->children.begin() + i + 1, s.right);
        }
        if ((int)node->keys.size() <= ORDER) return { 0, nullptr };
        return splitNode(node);
    }

    Split splitNode(Node* node) {
        size_t mid = node->keys.size() / 2;
        Node* right = new Node(node->leaf);
        int sep;
        if (node->leaf) {                     // COPY up: sep stays in right leaf
            right->keys.assign(node->keys.begin() + mid, node->keys.end());
            right->rids.assign(node->rids.begin() + mid, node->rids.end());
            node->keys.resize(mid);
            node->rids.resize(mid);
            right->next = node->next;         // fix leaf chain
            node->next = right;
            sep = right->keys[0];
        } else {                              // PUSH up: middle key moves up
            sep = node->keys[mid];
            right->keys.assign(node->keys.begin() + mid + 1, node->keys.end());
            right->children.assign(node->children.begin() + mid + 1, node->children.end());
            node->keys.resize(mid);
            node->children.resize(mid + 1);
        }
        return { sep, right };
    }
};

// --- demo ---
int main() {
    BPlusTree t;
    for (int k : { 10, 20, 30, 40, 50, 25 }) t.insert(k, k * 100);
    std::cout << search(t.root, 25) << "\n";   // 2500
    std::cout << search(t.root, 99) << "\n";   // -1 (not found)
}
// B+ tree insertion with leaf split + key promotion.
// Reuses the Node class from the search example above.
import java.util.*;

class BPlusTree {
    static final int ORDER = 3;          // max keys per node
    Node root = new Node(true);

    // right == null means "no split".
    static class Split { int sep; Node right; Split(int s, Node r){ sep=s; right=r; } }

    void insert(int key, int rid) {
        Split s = insertRec(root, key, rid);
        if (s.right != null) {              // root split -> grow a new root
            Node nr = new Node(false);
            nr.keys.add(s.sep);
            nr.children.add(root);
            nr.children.add(s.right);
            root = nr;
        }
    }

    Split insertRec(Node node, int key, int rid) {
        if (node.leaf) {
            int i = 0;
            while (i < node.keys.size() && node.keys.get(i) < key) i++;
            node.keys.add(i, key);
            node.rids.add(i, rid);
        } else {
            int i = 0;
            while (i < node.keys.size() && key >= node.keys.get(i)) i++;
            Split s = insertRec(node.children.get(i), key, rid);
            if (s.right == null) return s;
            node.keys.add(i, s.sep);
            node.children.add(i + 1, s.right);
        }
        if (node.keys.size() <= ORDER) return new Split(0, null);
        return splitNode(node);
    }

    Split splitNode(Node node) {
        int mid = node.keys.size() / 2;
        Node right = new Node(node.leaf);
        int sep;
        if (node.leaf) {                       // COPY up: sep stays in right leaf
            right.keys.addAll(node.keys.subList(mid, node.keys.size()));
            right.rids.addAll(node.rids.subList(mid, node.rids.size()));
            node.keys.subList(mid, node.keys.size()).clear();
            node.rids.subList(mid, node.rids.size()).clear();
            right.next = node.next;           // fix leaf chain
            node.next = right;
            sep = right.keys.get(0);
        } else {                               // PUSH up: middle key moves up
            sep = node.keys.get(mid);
            right.keys.addAll(node.keys.subList(mid + 1, node.keys.size()));
            right.children.addAll(node.children.subList(mid + 1, node.children.size()));
            node.keys.subList(mid, node.keys.size()).clear();
            node.children.subList(mid + 1, node.children.size()).clear();
        }
        return new Split(sep, right);
    }

    // --- demo ---
    public static void main(String[] a) {
        BPlusTree t = new BPlusTree();
        for (int k : new int[]{ 10, 20, 30, 40, 50, 25 }) t.insert(k, k * 100);
        System.out.println(search(t.root, 25));   // 2500
        System.out.println(search(t.root, 99));   // -1 (not found)
    }
}

Step-by-step worked example

Let's build a small tree where a node holds at most 3 keys (so it splits when a 4th would arrive). We insert: 10, 20, 30, 40, 50.

Insert 10, 20, 30 β€” the easy case
insert 10 β†’ [ 10 ]
insert 20 β†’ [ 10 | 20 ]
insert 30 β†’ [ 10 | 20 | 30 ]      # leaf now full (3 keys), still a single leaf = the root
          

So far no split β€” there was always room.

Insert 40 β€” the first leaf split (tree grows to 2 levels)

The leaf [10|20|30] is full and 40 needs to go in. We'd have [10,20,30,40] β†’ overflow β†’ split into two leaves around the middle:

                 [ new root ]
                 +--------+
                 |   30   |          # 30 = smallest key of right leaf, COPIED up
                 +--+--+--+
            <30   |     |  >=30
            v           v
        +----+----+   +----+----+
        | 10 | 20 | -->| 30 | 40 |  --> (null)
        +----+----+   +----+----+
          

Note: 30 stays in the right leaf (it's real data) and a copy went up to the new root as a separator. A brand-new root was created β†’ the tree is now 2 levels tall, and both leaves are at the same depth (balance preserved). The two leaves are chained left β†’ right.

Insert 50 β€” back to the easy case
                 +--------+
                 |   30   |
                 +--+--+--+
            v           v
        +----+----+   +----+----+----+
        | 10 | 20 | -->| 30 | 40 | 50 |  --> (null)
        +----+----+   +----+----+----+
          

50 belongs in the right leaf, which has room (only 2 of 3 slots used). Insert in sorted order, no split needed. The next insert into that right leaf (say 60) would overflow it and trigger another leaf split, copying a separator up into the root.

When the root itself splits (height +1)

Keep inserting and eventually the root fills with separators. When it overflows, it splits and the middle separator is pushed up into a fresh root holding just that one key with two children. This is the only moment a B+ tree gets taller β€” and it happens at the top, which is precisely why every leaf always stays on the same level. Deletes do the mirror image: if a node drops below half-full it borrows from a sibling or merges, occasionally shrinking the tree β€” we'll cover deletion fully in Session 7.

Complexity

OperationCostWhy
Insert (no split)O(logF n)Walk down to the leaf, insert in place.
Insert (with splits)O(logF n)Splits propagate up at most one level per step, and the height is logF n β€” so even cascading splits stay logarithmic.
Watch out β€” the write cost from Topic 1, in action

This is the "slower writes" trade-off made concrete. A single insert is usually cheap, but a split touches multiple pages (the leaf, its new sibling, the parent, sometimes more) and must be written back to disk. Every index on a table pays this on every insert. Useful indexes are worth it; needless ones just tax your writes.

Recap To insert, walk to the right leaf and add the key. If the node overflows, split it in half: leaves copy up a separator, internal nodes push up the middle key, and splits cascade upward. The tree only ever grows taller when the root splits β€” keeping it perfectly balanced. All of this stays O(logF n).

β˜… Putting it all together


You just learned how databases find rows fast. Here's the one-paragraph story that ties the four topics together:

Reading a whole table to find one row is a slow full scan, so databases build an index β€” a sorted (key β†’ pointer) shortcut. The workhorse index is the B+ tree: internal nodes are pure signposts and leaf nodes hold the real sorted keys, chained into a linked list. Thanks to high fanout, even a billion rows fit in ~4 levels, so a point lookup is just a few disk reads (O(logF n)) and a range scan glides along the leaf chain (O(logF n + k)). Inserts walk to a leaf and, when a node fills, split β€” copying up from leaves, pushing up from internal nodes β€” so the tree stays balanced and grows taller only by splitting the root. That balance is what makes reads, ranges, and writes all predictably fast.

Quick self-check

Why does an index make reads faster but writes slower?

Reads get faster because the sorted index lets you jump straight to the right page instead of scanning the whole table. Writes get slower because every insert/update/delete must also update the index (and may trigger splits) to keep it sorted and balanced.

In a B+ tree, where does the actual row data (or its pointer) live?

Only in the leaf nodes. Internal nodes hold separator keys and child pointers only β€” they're signposts. This keeps internal nodes small, raising fanout and lowering height.

Why is a B+ tree only ~4 levels deep even for 100 million rows?

High fanout: each node (one disk page) holds hundreds of child pointers, so capacity multiplies by ~hundreds per level. Height is O(logF n) with a large base F, so few levels cover enormous tables β€” meaning very few disk reads per lookup.

What makes range scans (e.g. BETWEEN 20 AND 50) efficient?

The leaf-level linked list. You search down once to the start leaf, then follow next pointers along the bottom row, collecting matches until you pass the upper bound β€” no climbing back up the tree. Cost: O(logF n + k).

When a leaf splits, why is the separator "copied up" but for an internal node it's "pushed up"?

Leaf keys are real data and must stay in the leaf, so a copy of the separator goes to the parent. Internal keys are only signposts, so the middle one moves up entirely (no duplication).

What is the only event that increases a B+ tree's height?

A root split. When the root overflows it splits and a new root is created above it. Because growth happens at the top, all leaves always remain at the same depth β€” the tree stays balanced.

πŸ“š References & Further Reading


Class material

Papers, docs & deep dives