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

How databases delete, build, and choose the right index

Welcome back! In Session 6 we built B+ trees and learned how they stay balanced while we insert keys. Today we finish the story: what happens when you delete keys, how to build a huge index from scratch the fast way, how to index on more than one column, and when a totally different structure β€” a hash index β€” beats a tree. As always, every topic starts with a tiny "explain like I'm 5" story, then we go deep with real diagrams, SQL, and pseudocode. No prior knowledge assumed beyond Session 6.

⏱ 22 min readπŸ“– 4 topics

1 Deletion & merges β€” underflow, borrowing, and shrinking


Explain like I'm 5

Imagine egg cartons on a shelf, and the rule is "every carton must be at least half full." If you take an egg out and a carton drops below half, you don't leave it nearly empty. First you ask the carton next door: "Spare me an egg?" If the neighbour has plenty, it lends one β€” easy. If the neighbour is also only half full, lending would break it, so instead you just tip both cartons into one and throw the empty carton away. B+ tree deletion is exactly this: borrow if you can, otherwise merge.

Recall from Session 6 that a B+ tree of order (also called the fan-out) n has these rules: every node holds at most n−1 keys, and every node except the root must stay at least half full. Inserting could make a node too full, so we split. Deleting can make a node too empty β€” a condition called underflow β€” and the cures are the mirror image of a split: borrow (also called redistribution) or merge (also called coalescing).

The minimum-occupancy rule

The whole reason a B+ tree gives us guaranteed O(log n) lookups is that it stays balanced and reasonably full. If we let nodes drain to almost empty, the tree would get tall and wasteful. So the structure enforces a floor:

Node typeMax keysMinimum keys (the floor)
Leaf noden−1⌈(n−1)/2⌉
Internal noden−1⌈n/2⌉ − 1 (i.e. at least ⌈n/2⌉ children)
Rootn−11 key (the root is exempt from the floor)

When a deletion drops a node below its floor, we have underflow and must fix it before we finish.

The deletion algorithm, step by step

πŸ”
1. Find leaf
Walk down to the leaf holding the key
β†’
βœ‚οΈ
2. Remove key
Delete the entry from that leaf
β†’
πŸ“
3. Check fill
Still at least half full? Done.
β†’
🀝
4. Borrow?
A sibling has spare keys? Redistribute.
β†’
🧩
5. Merge
Else fuse with a sibling, fix parent

Two important details that beginners trip over:

  • In a B+ tree, all real data lives in the leaves. Internal nodes only hold copies of keys used as signposts. So a deleted key might still appear in an internal node as a separator β€” and that's fine; it's just a routing label, not a missing/duplicate row.
  • After a borrow or merge, the parent's separator key may need updating, and a merge removes a key from the parent β€” which can make the parent underflow too. So fixes can cascade upward, exactly the way splits cascade upward during insertion.

Borrowing (redistribution)

If an underflowing node has an adjacent sibling with keys to spare (i.e. the sibling is more than half full), we move one key across and patch the parent's separator. This is cheap and local β€” no nodes are deleted.

Merging (coalescing)

If neither sibling can spare a key (both are exactly at the floor), borrowing would just push the problem to the sibling. Instead we merge the underflowing node with a sibling into one node. Because two half-full nodes combine to at most one full node, this is always legal. Merging two children means the parent loses one child and one separator key β€” which is why the parent can underflow and the fix may climb the tree.

Worked example: delete from an order-4 B+ tree

Order n = 4 means each node holds up to 3 keys, and leaves must keep at least ⌈3/2⌉ = 2 keys. Start with this tree (leaves chained leftβ†’right; the root holds separator keys):

          root:        [ 13 | 25 ]
                     /     |      \
   leaf A:[5, 9]  leaf B:[13,17]  leaf C:[25,30,33]
       (chained:  A <-> B <-> C)

Case 1 β€” easy delete (no underflow). Delete 30 from leaf C. C had 3 keys, now has [25, 33] β€” still β‰₯ 2. Done, nothing else changes.

Case 2 β€” borrow from a sibling. Now delete 17 from leaf B. B drops to [13] β€” only 1 key, that's underflow. Look at a sibling: C is [25, 33] (2 keys, exactly the floor β€” can't spare). But suppose instead C still held [25, 30, 33] (3 keys, one to spare). Then B borrows C's smallest key, 25:

          root:        [ 13 | 30 ]   ← separator updated 25→30
                     /     |      \
   leaf A:[5, 9]  leaf B:[13,25]  leaf C:[30,33]

Notice the parent's separator between B and C changed from 25 to 30 β€” it must always equal the smallest key in the right child.

Case 3 β€” merge (no sibling can spare). Back to the original tree, delete 17 from B so B = [13] (underflow), and C is only [25, 30, 33]… actually let's force the merge case: say C = [25, 30] (at the floor, can't lend). Now B can't borrow, so B and C merge into one leaf [13, 25, 30]. The parent loses the separator 25 and one child pointer:

          root:        [ 13 ]          ← lost separator 25
                     /      \
        leaf A:[5,9]   leaf BC:[13,25,30]

If that left the parent below its own floor, we'd repeat borrow-or-merge one level up.

When the height actually shrinks

Splits during insertion grow the tree taller only in one special case: when the root itself splits. Deletion shrinks the tree in the mirror case: when a merge propagates all the way up and the root ends up with no keys left (only a single child pointer). Then we discard the old root and promote its only child to be the new root β€” the tree loses one level.

Watch out β€” the height only changes at the root

A common misconception is that any merge makes the tree shorter. It doesn't. Most merges just fuse two nodes at one level. The height drops only when a merge empties the root, just as the height grows only when a split overflows the root. Everywhere in between, the tree quietly rebalances at a single level.

A practical shortcut real databases take

Strictly enforcing merges on every delete is expensive. Many real systems (including the B-tree in PostgreSQL and others) use lazy deletion: they mark entries as dead and let pages sit under-full, only reclaiming or merging space later during a cleanup pass (e.g. PostgreSQL's VACUUM). The textbook algorithm above is the logical model; production systems optimise around it. We'll touch on storage reclamation in a later session.

Recap Deleting a key can cause underflow (a node drops below half full). Fix it by borrowing a key from a sibling that has spares, or by merging with a sibling when no one can spare. Merges remove a separator from the parent, so fixes can cascade upward. The tree's height shrinks only when a merge empties the root and its single child is promoted.

2 Bulk loading β€” building a B+ tree the fast way


Explain like I'm 5

Imagine you have a thousand library books to shelve. One way: pick up each book, walk to the right shelf, squeeze it in, and shuffle the whole row over to make room β€” a thousand separate trips. The faster way: first sort all the books into neat piles on the floor, then fill each shelf completely from left to right, then write the shelf labels last. Same books, way less walking. Bulk loading builds an index that second way.

If you already have a big pile of data (say you're creating an index on an existing table, or loading millions of rows), you could just call insert once per key. But each insert from Session 6 walks from the root to a leaf and may trigger splits β€” that's O(log n) work and lots of random page touches, per key. Bulk loading (also called bulk insertion or bottom-up construction) builds the tree far faster.

The algorithm

πŸ”’
1. Sort
Sort all keys (with their record pointers)
β†’
πŸ“š
2. Pack leaves
Fill leaf pages left-to-right
β†’
πŸ”—
3. Chain
Link leaves into the sequence set
β†’
⬆️
4. Build up
Make a parent level from leaf min-keys
β†’
🌳
5. Repeat
Until one node remains: the root

The key idea is bottom-up: instead of growing the tree top-down one insert at a time, you lay down the entire bottom row of leaves first (each filled to a chosen capacity), then build each higher level by taking the smallest key of each child as a separator in its parent. You repeat until a single node β€” the root β€” remains.

Worked example: bulk-load 10 sorted keys

Keys (already sorted): 2, 3, 5, 7, 11, 13, 17, 19, 23, 29. Suppose leaves hold up to 3 keys and we choose to fill them ~2/3 full to leave room for later inserts.

Step 1 β€” pack and chain the leaves left to right:

  L1:[2,3]  L2:[5,7]  L3:[11,13]  L4:[17,19]  L5:[23,29]
     (chained: L1 <-> L2 <-> L3 <-> L4 <-> L5)

Step 2 β€” build the parent level. Each internal entry routes to a child, and a B+ tree separator equals the smallest key reachable in the child to its right. Taking the first key of L2, L3, L4, L5 gives separators 5, 11, 17, 23. With fan-out 4 we can fit them in one internal node β€” which becomes the root:

  root: [ 5 | 11 | 17 | 23 ]
         /    |    |    |    \
       L1    L2   L3   L4    L5

Done β€” a complete, balanced 2-level tree built in essentially one sorted pass, with no splits at all. (If the separators didn't fit in one node, we'd pack them into several internal nodes and build yet another level on top, repeating until one root remains.)

Why bulk loading is faster

AspectRepeated insertsBulk loading
Cost per keyRoot-to-leaf descent + possible splits, each timeOne sort, then a single sequential pass
Total cost~O(k Β· log k) with random I/O per insert~O(k Β· log k) for the sort, then O(k) sequential build
Disk access patternRandom page reads/writes (cache-unfriendly)Sequential scans (cache- and disk-friendly)
Node splitsMany, scattered through the buildNone β€” leaves are filled in order
Key takeaway β€” better packing

Random inserts tend to leave B+ tree pages only about ~67% full on average (a classic result), because splits cut a full page in half. Bulk loading lets you choose the fill factor deliberately β€” pack pages 100% full for a read-only index (fewer pages, fewer I/Os, shallower tree), or leave headroom (e.g. 67–90%) if you expect future inserts so you don't immediately trigger splits. You control the packing instead of leaving it to chance.

Trade-off: pack too tight and the next insert splits

A 100%-full index is wonderfully compact for reads, but the very first insert into any full leaf forces an immediate split. If the table will keep growing, set a lower fill factor (PostgreSQL exposes exactly this as the fillfactor storage parameter on an index). Read-mostly? Pack tight. Write-heavy? Leave slack.

Recap Bulk loading builds a B+ tree bottom-up from sorted data: sort once, fill leaf pages left-to-right, chain them, then build parent levels from each child's smallest key until one root remains. It's far faster than repeated inserts (sequential I/O, no scattered splits) and lets you choose the fill factor for better, deliberate packing.

3 Composite & covering indexes


Explain like I'm 5

Think of a phone book sorted by last name, then first name. It's brilliant for "find all the Smiths" and "find John Smith" β€” because last name comes first. But it's useless for "find everyone named John," because all the Johns are scattered across every letter. The order of the columns in a multi-column index is just like the order in that phone book: it decides which questions are easy and which are impossible.

So far our index keys were a single column. A composite index (also called a compound or multi-column index) uses several columns combined into one key. The B+ tree sorts rows by the first column, then breaks ties with the second, then the third, and so on β€” exactly the phone-book ordering. This is also called lexicographic (dictionary) order.

Column order matters β€” a lot

Consider an index on (last_name, first_name). Because the tree is sorted by last_name first, here is what it can and can't accelerate:

QueryUses the index?Why
WHERE last_name = 'Smith'βœ… YesMatches the leading column β€” one contiguous range.
WHERE last_name = 'Smith' AND first_name = 'John'βœ… YesUses both columns; pins down an exact spot.
WHERE last_name = 'Smith' AND first_name LIKE 'J%'βœ… YesLeading column exact, second column is a range β€” still contiguous.
WHERE first_name = 'John'❌ No (usually)Skips the leading column; the Johns are scattered everywhere.
The leftmost-prefix rule

A composite index on (A, B, C) can efficiently serve queries that filter on a leftmost prefix of the columns: A; A, B; or A, B, C. It generally cannot serve a query that filters only on B, or only on C, or on B, C without A β€” because those columns aren't the thing the tree is sorted by first. Put the column you most often filter by first.

Worked example: same data, two column orders

Say we frequently run WHERE city = ? AND age > ?. Compare:

  -- Index option A: city first, then age
  CREATE INDEX idx_city_age ON users (city, age);

  -- Index option B: age first, then city
  CREATE INDEX idx_age_city ON users (age, city);

  -- The query:
  SELECT * FROM users
  WHERE city = 'Delhi' AND age > 30;

Option A (city, age) is ideal: the tree jumps straight to the contiguous block of Delhi rows, and within Delhi the rows are already sorted by age, so age > 30 is one clean range scan from age 31 onward. Option B (age, city) is poor for this query: it would scan every age > 30 and then filter for Delhi inside each age, touching far more of the index. Same columns β€” the order decided everything.

Covering indexes β€” answering a query from the index alone

Normally an index entry gets you to the right location, and then the database does an extra step: it follows the pointer to the actual table row to fetch the other columns you asked for. That extra hop is the slow part for big result sets. A covering index contains every column the query needs β€” both the columns it filters on and the columns it returns β€” so the database can answer the query entirely from the index, never touching the table. This is called an index-only scan.

Worked example: turning a query into an index-only scan

The query we want to make fast:

  SELECT email
  FROM users
  WHERE city = 'Delhi';

A plain index on (city) finds the Delhi rows fast, but must then visit each table row to read email. Make it a covering index by including email too:

  -- email is part of the sorted key here
  CREATE INDEX idx_city_email ON users (city, email);

  -- PostgreSQL: keep email as a non-key "payload" via INCLUDE
  CREATE INDEX idx_city_inc ON users (city) INCLUDE (email);

Now both city (to filter) and email (to return) live in the index, so the query is answered index-only β€” no table lookups at all. The INCLUDE form (PostgreSQL, SQL Server) stores email only in the leaf level as extra payload, so it covers the query without bloating the upper tree or affecting sort order.

Prefix queries, one more time

"Prefix" shows up in two senses here and they're related: (1) a column prefix β€” using a leftmost subset of a composite index's columns; and (2) a value prefix β€” a search like name LIKE 'Sha%', which a B+ tree handles as a single range scan because all strings starting with "Sha" sort next to each other. Both work because the tree keeps keys in sorted order. (A leading-wildcard search like LIKE '%ya' can't use the tree β€” the start is unknown, so there's no contiguous range.)

Trade-off: indexes aren't free

Wider composite and covering indexes make reads faster but cost more to store and to keep updated β€” every INSERT/UPDATE/DELETE on the table must also maintain every index. Add the columns a query truly needs, not every column "just in case."

Recap A composite index sorts by its columns left to right, so column order matters: it serves any leftmost prefix of the columns but not a non-leading column alone. A covering index contains every column a query touches, enabling an index-only scan with no trip to the table β€” at the cost of a wider, pricier-to-maintain index.

4 Hash indexes vs B+ trees


Explain like I'm 5

A B+ tree is like a sorted bookshelf β€” great for "give me every book between G and M." A hash index is like a coat-check: you hand over your ticket number, the attendant instantly knows the exact peg, and fetches your coat in one move. Lightning fast for "this exact ticket" β€” but if you ask "give me all the coats with tickets between 40 and 60," the attendant is useless, because the pegs aren't in number order at all.

A hash index is built on a hash function: a little formula that turns a key (like user_id = 8421) into a bucket number β€” a slot where the matching entry is stored. To look up a key, you hash it, jump straight to that bucket, and scan the (usually tiny) bucket. No tree to descend.

The big idea

A hash index trades order for speed. By scattering keys into buckets by a hash, it gives roughly O(1) exact-match lookups β€” faster than a tree's O(log n) descent. But the scattering destroys sorted order, so it cannot do range scans, prefix matches, or sorted output at all. That single trade-off drives every "which should I use?" decision.

Static hashing β€” and why it's not enough

The simplest scheme, static hashing, fixes the number of buckets up front: bucket = hash(key) mod N. That's fine until the table grows. When buckets fill up, new entries spill into overflow chains (extra linked pages hanging off a bucket), and lookups degrade from O(1) toward O(chain length). Sizing too big wastes space; too small causes overflows. We need a scheme that grows gracefully.

Extendible hashing

Extendible hashing uses a directory that points to buckets, and looks at the first d bits of the hash (d is the global depth). When one bucket overflows, only that bucket is split (and the directory doubles only if needed), so the structure grows one bucket at a time rather than rehashing everything. Lookups stay close to a single bucket access.

Linear hashing

Linear hashing grows the table one bucket at a time in a fixed round-robin order, with no directory at all. A "split pointer" marches across the buckets; whenever the table gets too full overall, the next bucket in line is split. It avoids the directory entirely and spreads the cost of growth smoothly, which is why several real systems use it.

Worked example: a static hash lookup & a bucket split

Suppose N = 4 buckets and hash(k) = k (identity, for clarity). Insert keys 5, 9, 13, 7:

  bucket = key mod 4
  5  mod 4 = 1   → bucket 1
  9  mod 4 = 1   → bucket 1
  13 mod 4 = 1   → bucket 1   (bucket 1 now: [5, 9, 13])
  7  mod 4 = 3   → bucket 3

  Buckets:  0:[ ]   1:[5,9,13]   2:[ ]   3:[7]

Lookup key = 9: compute 9 mod 4 = 1, jump straight to bucket 1, scan its 3 entries, find 9. That's a single bucket access β€” O(1) expected, independent of how many total keys exist.

Now suppose buckets hold at most 2 entries. Bucket 1 has 3 β€” it overflowed. Under static hashing the third entry goes to an overflow page chained off bucket 1 (lookups for bucket 1 now cost 2 page reads). Under extendible/linear hashing we'd instead split bucket 1 using one more hash bit, redistributing [5, 9, 13] into two buckets so each stays small and lookups stay ~O(1).

The comparison table β€” when to use which

CapabilityB+ tree indexHash index
Exact match (=)Good β€” O(log n)Excellent β€” O(1) expected
Range scan (<, >, BETWEEN)βœ… Excellent β€” leaves are chained in order❌ Not supported β€” buckets aren't ordered
Prefix match (LIKE 'abc%')βœ… Yes (range scan)❌ No
Sorted output / ORDER BYβœ… Free β€” already sorted❌ Needs a separate sort
Min / Maxβœ… Walk to leftmost/rightmost leaf❌ Must scan all buckets
Composite leftmost-prefix queriesβœ… Yes⚠️ Only on the full key
Growth behaviourSelf-balancing via split/mergeNeeds extendible/linear hashing to grow well
The decision rule

Use a hash index only when all you ever do is exact-equality lookups on the key (e.g. a pure key-value cache, a join on an ID). Use a B+ tree for essentially everything else β€” anything with ranges, sorting, prefixes, min/max, or partial-key queries. Because B+ trees handle equality well enough and ranges brilliantly, they are the default index in every major database (it's what you get from a plain CREATE INDEX).

In the real world

PostgreSQL ships both: the default btree and an explicit USING hash (CREATE INDEX ... USING hash (col)), useful for large equality-only columns. MySQL's MEMORY engine uses hash indexes by default, while InnoDB uses B+ trees (with an automatic "adaptive hash index" layered on top for hot equality lookups). The takeaway: B+ trees are the workhorse; hash indexes are a specialist tool.

Recap A hash index maps keys to buckets for ~O(1) exact-match lookups but no range scans, prefixes, sorting, or min/max. Static hashing can't grow well (overflow chains), so we use extendible hashing (a doubling directory + split one bucket) or linear hashing (round-robin splits, no directory). Reach for a hash index only for pure equality workloads; otherwise the B+ tree is the default for its range power.

β˜… Putting it all together


You've now seen the full life of an index β€” building it, shrinking it, shaping it for multiple columns, and choosing a completely different structure when the workload calls for it. Here's the one-paragraph story tying today's four topics together:

A B+ tree stays balanced not just on insert (Session 6) but on delete: when a node underflows we borrow from a sibling or merge, and the tree only loses height when a merge empties the root. When we have lots of data up front, bulk loading builds the tree bottom-up from sorted keys β€” faster and with deliberate packing via the fill factor. To serve real queries we use composite indexes, where column order obeys the leftmost-prefix rule, and covering indexes, which carry every column a query needs so it runs index-only. And when a workload is pure equality lookups with no ranges, a hash index (extendible or linear) gives O(1) access β€” but for everything involving order, the B+ tree remains the default. These structures are exactly what the query planner reasons about when it decides how to run your SQL β€” which is where we head next in Session 8.

Quick self-check

A leaf underflows and both its siblings are exactly at the minimum. Borrow or merge?

Merge. Neither sibling can spare a key without underflowing itself, so you fuse the node with a sibling into one node and remove the now-unneeded separator from the parent (which may cause the parent to underflow too).

When does a B+ tree's height actually decrease?

Only when a merge propagates all the way up and empties the root, leaving it with a single child. That child is promoted to be the new root and the tree loses one level. Ordinary merges lower down don't change the height.

Why is bulk loading faster than inserting keys one by one?

It sorts once, then fills leaves left-to-right in a single sequential pass with no scattered node splits and disk-friendly sequential I/O. It also lets you pick the fill factor for deliberate packing, instead of leaving pages ~67% full from random splits.

You have an index on (country, city). Which query can't use it efficiently: filtering by country, or filtering only by city?

Filtering only by city. The tree is sorted by country first, so cities are scattered across every country β€” there's no contiguous range. Filtering by country (the leftmost prefix) works great.

What makes an index a "covering" index, and what does it avoid?

It contains every column a query needs β€” both the filter columns and the returned columns β€” so the query is answered index-only. It avoids the extra hop from the index entry to the actual table row (the table lookups).

Your query is WHERE price BETWEEN 100 AND 200 ORDER BY price. B+ tree or hash index?

B+ tree. It's a range query that also wants sorted output β€” both impossible for a hash index, which has no order. The B+ tree finds the start of the range and walks the chained leaves in sorted order for free.

πŸ“š References & Further Reading


Class material

Papers, docs & deep dives