1 Deletion & merges β underflow, borrowing, and shrinking
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 type | Max keys | Minimum keys (the floor) |
|---|---|---|
| Leaf node | n−1 | ⌈(n−1)/2⌉ |
| Internal node | n−1 | ⌈n/2⌉ − 1 (i.e. at least ⌈n/2⌉ children) |
| Root | n−1 | 1 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
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.
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.
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.
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.
2 Bulk loading β building a B+ tree the fast way
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
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.
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
| Aspect | Repeated inserts | Bulk loading |
|---|---|---|
| Cost per key | Root-to-leaf descent + possible splits, each time | One 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 pattern | Random page reads/writes (cache-unfriendly) | Sequential scans (cache- and disk-friendly) |
| Node splits | Many, scattered through the build | None β leaves are filled in order |
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.
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.
3 Composite & covering indexes
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:
| Query | Uses the index? | Why |
|---|---|---|
WHERE last_name = 'Smith' | β Yes | Matches the leading column β one contiguous range. |
WHERE last_name = 'Smith' AND first_name = 'John' | β Yes | Uses both columns; pins down an exact spot. |
WHERE last_name = 'Smith' AND first_name LIKE 'J%' | β Yes | Leading column exact, second column is a range β still contiguous. |
WHERE first_name = 'John' | β No (usually) | Skips the leading column; the Johns are scattered everywhere. |
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.
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.
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" 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.)
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."
4 Hash indexes vs B+ trees
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.
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.
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
| Capability | B+ tree index | Hash 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 behaviour | Self-balancing via split/merge | Needs extendible/linear hashing to grow well |
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).
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.
β 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
- π Original course notes / handout (source sheet) β open the shared class material for this session.
- DBMS Session 7 β Index Structures 2 (your course handout for this session).
Papers, docs & deep dives
- CMU 15-445 Database Systems β tree & hash index lectures β Andy Pavlo's free course; the B+ tree and hashing lectures cover deletion, bulk loading, and extendible/linear hashing with excellent slides and videos.
- PostgreSQL Documentation β Index Types β the official rundown of B-tree vs hash (and GIN/GiST/BRIN), with exactly when each is appropriate; pairs directly with Topics 3 and 4.
- Silberschatz, Korth & Sudarshan β Database System Concepts (the "Indexing" & hashing chapters) β the standard textbook treatment of B+ tree deletion, bulk loading, and static/dynamic hashing with full worked examples.
- Markus Winand β Use The Index, Luke! β a famously clear, practical guide to composite indexes, the leftmost-prefix rule, and covering/index-only scans across real SQL databases.
- PostgreSQL β
CREATE INDEXreference β documentsINCLUDE(covering indexes),fillfactor(packing), andUSING hash, the exact features used in this session's examples.