B-Tree Operations: Search, Insert, and Delete
Delete Algorithm
Read aloud is unavailable in this browser.
Maintaining Balance: The Art of B-Tree Deletion
B-tree deletion is arguably the most complex of the three core operations. While search is straightforward and insertion needs only splitting, deletion must handle underflow through multiple strategies: borrowing from siblings, merging with siblings, and rebalancing separators in ancestors.
Every DELETE FROM statement that affects an indexed column triggers B-tree deletions across all relevant indexes. Understanding this operation is essential for maintaining database performance and ensuring index integrity.
By completing this page, you will understand: (1) The complete deletion algorithm with all cases, (2) When and how to borrow keys from siblings, (3) When and why node merging occurs, (4) Handling deletion of keys in internal nodes, (5) Cascading rebalancing through the tree, and (6) Production considerations including lazy deletion and tombstones.
Deletion Fundamentals and Invariants
Deletion must preserve all B-tree invariants while removing a key. The challenge arises when removal would leave a node with fewer than the minimum required keys.
Invariants that Deletion Must Maintain:
- Minimum Occupancy: Every non-root node must have at least ⌈t/2⌉-1 keys (typically t-1 for minimum degree t)
- Maximum Occupancy: No node exceeds 2t-1 keys
- Ordering: Keys within nodes and across the tree remain sorted
- Balance: All leaves remain at the same depth
- Root Occupancy: Root can have as few as 1 key (or 0 if tree becomes empty)
The Underflow Problem:
When a node has exactly t-1 keys (minimum allowed) and we delete one key:
- The node would have t-2 keys
- This violates the minimum occupancy invariant
- We must rebalance by borrowing from siblings or merging
| Scenario | Before Deletion | After Naive Delete | Invariant Violated? | Action Needed |
|---|---|---|---|---|
| Node has spare keys | k keys (k > t-1) | k-1 keys | No | None—simple delete |
| Node at minimum | t-1 keys | t-2 keys | Yes—underflow | Borrow or merge |
| Root with 1 key | 1 key | 0 keys | Depends | Collapse tree height |
Deletion is the mirror of insertion: insertion causes overflow (→ split), deletion causes underflow (→ merge). However, deletion is more complex because we may need to delete from internal nodes (not just leaves), requiring predecessor/successor strategies.
The Three Deletion Cases
B-tree deletion breaks down into three fundamental cases based on where the key is located:
Case 1: Key is in a Leaf Node
The simplest case—if removing the key doesn't cause underflow, simply remove it. If underflow occurs, rebalance with siblings.
Case 2: Key is in an Internal Node
We cannot simply remove from an internal node because it serves as a separator. Instead, replace it with its predecessor or successor from a leaf, then delete that replacement from the leaf.
Case 3: Key is Not in Current Node
The key might be in a subtree. Ensure the child we descend into has enough keys to handle a potential deletion (proactive rebalancing), then recurse.
High-Level Algorithm:
BTREE-DELETE(node, key):
i ← FindKeyIndex(node, key) // Position where key is or would be
IF key is found at node.keys[i]: // Key in this node
IF node.isLeaf:
CASE 1: Delete from leaf
ELSE:
CASE 2: Delete from internal node
ELSE: // Key not in this node
IF node.isLeaf:
RETURN "Key not found" // Base case: key doesn't exist
ELSE:
CASE 3: Ensure child has enough keys, then recurse
Let's examine each case in detail.
Similar to proactive splitting during insertion, proactive rebalancing ensures that when we descend into a child, it has at least t keys (one more than minimum). This guarantees we can always delete without needing to propagate rebalancing upward.
Case 1: Deletion from Leaf Nodes
Leaf deletion is the base case—the key and its associated data are directly removed. The complexity arises from maintaining minimum occupancy.
Sub-case 1a: Leaf has spare keys
If the leaf has more than t-1 keys, simply remove the key:
DELETE-FROM-LEAF(leaf, keyIndex):
// Shift keys and values left to close the gap
FOR i ← keyIndex TO leaf.numKeys - 2:
leaf.keys[i] ← leaf.keys[i + 1]
leaf.values[i] ← leaf.values[i + 1]
leaf.numKeys ← leaf.numKeys - 1
DISK-WRITE(leaf)
Example:
Before: [10, 20, 30, 40] (t=3, min=2 keys, current=4)
Delete 20: [10, 30, 40] (3 keys ≥ 2, OK)
Sub-case 1b: Leaf will underflow—borrow from sibling
If deleting would cause underflow but a sibling has spare keys, borrow:
BORROW-FROM-LEFT-SIBLING(parent, nodeIndex):
node ← parent.children[nodeIndex]
leftSibling ← parent.children[nodeIndex - 1]
// Make room in node by shifting keys right
FOR i ← node.numKeys - 1 DOWNTO 0:
node.keys[i + 1] ← node.keys[i]
node.values[i + 1] ← node.values[i]
// Pull separator from parent into node's first position
node.keys[0] ← parent.keys[nodeIndex - 1]
// Move sibling's last key up to parent as new separator
parent.keys[nodeIndex - 1] ← leftSibling.keys[leftSibling.numKeys - 1]
// Transfer sibling's last value to node
node.values[0] ← leftSibling.values[leftSibling.numKeys - 1]
leftSibling.numKeys ← leftSibling.numKeys - 1
node.numKeys ← node.numKeys + 1
DISK-WRITE(leftSibling)
DISK-WRITE(node)
DISK-WRITE(parent)
Example:
Before:
Parent: [..., 30, ...]
/
[10, 20, 25] [40] ← Need to delete 40, will underflow
Borrow from left sibling:
Parent: [..., 25, ...]
/
[10, 20] [30, 40]
Now delete 40:
Parent: [..., 25, ...]
/
[10, 20] [30] ← Valid (≥ min keys)
Check both left and right siblings for spare keys. Prefer the sibling with more keys (reduces future borrowing) or the one that avoids expensive I/O (if one is already cached). Some implementations always try left first for consistency.
Case 1c: Leaf Merging When Siblings Can't Spare
When no sibling has spare keys to borrow, we must merge the underflowing node with a sibling. This is the inverse of splitting.
Merge Algorithm:
MERGE-NODES(parent, leftIndex):
// Merge children[leftIndex] and children[leftIndex + 1]
leftNode ← parent.children[leftIndex]
rightNode ← parent.children[leftIndex + 1]
// Pull separator key from parent into left node
leftNode.keys[leftNode.numKeys] ← parent.keys[leftIndex]
leftNode.numKeys ← leftNode.numKeys + 1
// Copy all keys from right node to left node
FOR j ← 0 TO rightNode.numKeys - 1:
leftNode.keys[leftNode.numKeys] ← rightNode.keys[j]
leftNode.values[leftNode.numKeys] ← rightNode.values[j]
leftNode.numKeys ← leftNode.numKeys + 1
// If internal nodes, also copy child pointers
IF NOT leftNode.isLeaf:
FOR j ← 0 TO rightNode.numKeys:
leftNode.children[leftNode.numKeys - rightNode.numKeys + j] ← rightNode.children[j]
// Remove separator and right child from parent
FOR j ← leftIndex TO parent.numKeys - 2:
parent.keys[j] ← parent.keys[j + 1]
parent.children[j + 1] ← parent.children[j + 2]
parent.numKeys ← parent.numKeys - 1
// Free the right node
FREE-NODE(rightNode)
DISK-WRITE(leftNode)
DISK-WRITE(parent)
Merge Example (t=3, min keys=2):
Before (need to delete 60 from right leaf):
Parent: [50]
/
[30, 40] [60] ← Will underflow (has minimum)
Both siblings at minimum, cannot borrow.
Merge left + separator + right:
Parent: [] ← Empty! Might collapse if root
|
[30, 40, 50, 60]
Now delete 60:
Parent: []
|
[30, 40, 50] ← Valid leaf
Key insight: Merging reduces the parent's key count by 1 (the separator is consumed). If the parent was at minimum, this triggers cascading rebalancing.
The merge must happen BEFORE the actual key deletion. Merge ensures the node has enough keys; then deletion removes one key, leaving at least t-1. Reversing this order would temporarily violate invariants.
B+-Tree Leaf Merge Specifics:
For B+-tree leaves, merging also requires updating the sibling linked list:
MERGE-LEAF-NODES(parent, leftIndex):
// ... standard merge operations ...
// Update leaf linked list
leftNode.nextLeaf ← rightNode.nextLeaf
IF rightNode.nextLeaf ≠ NULL:
rightNode.nextLeaf.prevLeaf ← leftNode
Forgetting this update breaks range query traversal after the merge point.
Case 2: Deletion from Internal Nodes
Deleting a key from an internal node is more complex because internal keys serve as separators—they define the boundaries between subtrees. We cannot simply remove them without breaking the tree structure.
Strategy: Replace and Delete
Instead of removing the key directly, replace it with its in-order predecessor or in-order successor (both are in leaves), then delete that replacement from the leaf.
[key to delete: 50]
/
[..., 45] [55, ...]
↑ ↑
Predecessor Successor
(45) (55)
Predecessor: The rightmost key in the left subtree Successor: The leftmost key in the right subtree
Both are guaranteed to be in leaf nodes, converting Case 2 to Case 1.
Algorithm for Case 2:
DELETE-FROM-INTERNAL(node, keyIndex):
key ← node.keys[keyIndex]
// Case 2a: Left child has enough keys
IF node.children[keyIndex].numKeys >= t:
predecessor ← GET-PREDECESSOR(node.children[keyIndex])
node.keys[keyIndex] ← predecessor.key
BTREE-DELETE(node.children[keyIndex], predecessor.key)
// Case 2b: Right child has enough keys
ELSE IF node.children[keyIndex + 1].numKeys >= t:
successor ← GET-SUCCESSOR(node.children[keyIndex + 1])
node.keys[keyIndex] ← successor.key
BTREE-DELETE(node.children[keyIndex + 1], successor.key)
// Case 2c: Both children have minimum keys—merge, then delete
ELSE:
MERGE-NODES(node, keyIndex) // Merges children[keyIndex] and [keyIndex+1]
BTREE-DELETE(node.children[keyIndex], key) // Key now in merged node
GET-PREDECESSOR(node):
// Navigate to rightmost leaf, return rightmost key
WHILE NOT node.isLeaf:
node ← node.children[node.numKeys]
RETURN node.keys[node.numKeys - 1]
GET-SUCCESSOR(node):
// Navigate to leftmost leaf, return leftmost key
WHILE NOT node.isLeaf:
node ← node.children[0]
RETURN node.keys[0]
| Sub-case | Condition | Action |
|---|---|---|
| 2a | Left child has ≥ t keys | Replace with predecessor, delete predecessor from left subtree |
| 2b | Right child has ≥ t keys | Replace with successor, delete successor from right subtree |
| 2c | Both children have t-1 keys | Merge children with separator, then delete from merged node |
We check for ≥ t keys (not just > t-1) because we're about to delete from that subtree. Having t keys means after deletion the child will have t-1 keys (still valid). Having exactly t-1 keys means deletion would cause underflow.
Case 3: Proactive Rebalancing During Descent
When the key to delete is not in the current node, we must descend to a child. But what if that child has exactly t-1 keys? Deleting from it would cause underflow.
Proactive Rebalancing: Before descending, ensure the target child has at least t keys. This guarantees that deletion can proceed without upward propagation.
CASE-3-ENSURE-CHILD-HAS-ENOUGH(node, childIndex):
child ← node.children[childIndex]
IF child.numKeys >= t:
RETURN // Already has enough keys
// Child has exactly t-1 keys; need to add one
leftSibling ← childIndex > 0 ? node.children[childIndex - 1] : NULL
rightSibling ← childIndex < node.numKeys ? node.children[childIndex + 1] : NULL
// Case 3a: Borrow from left sibling
IF leftSibling ≠ NULL AND leftSibling.numKeys >= t:
BORROW-FROM-LEFT(node, childIndex)
// Case 3b: Borrow from right sibling
ELSE IF rightSibling ≠ NULL AND rightSibling.numKeys >= t:
BORROW-FROM-RIGHT(node, childIndex)
// Case 3c: Can't borrow; merge with a sibling
ELSE:
IF leftSibling ≠ NULL:
MERGE-NODES(node, childIndex - 1) // Merge child with left sibling
childIndex ← childIndex - 1 // Merged node is at original left sibling's position
ELSE:
MERGE-NODES(node, childIndex) // Merge child with right sibling
// Now descend into the child (which now has enough keys)
RETURN node.children[childIndex]
Borrowing in Detail:
BORROW-FROM-LEFT(parent, childIndex):
child ← parent.children[childIndex]
leftSibling ← parent.children[childIndex - 1]
// Shift child's keys/children right to make room
FOR i ← child.numKeys DOWNTO 0:
child.keys[i + 1] ← child.keys[i]
IF NOT child.isLeaf:
FOR i ← child.numKeys + 1 DOWNTO 0:
child.children[i + 1] ← child.children[i]
// Parent's separator moves down to child
child.keys[0] ← parent.keys[childIndex - 1]
// Sibling's rightmost key moves up to parent
parent.keys[childIndex - 1] ← leftSibling.keys[leftSibling.numKeys - 1]
// If internal, also transfer sibling's rightmost child
IF NOT child.isLeaf:
child.children[0] ← leftSibling.children[leftSibling.numKeys]
leftSibling.numKeys ← leftSibling.numKeys - 1
child.numKeys ← child.numKeys + 1
The rotation brings the separator down and the sibling's key up, maintaining order.
Like proactive splitting for insertion, proactive rebalancing enables single-pass deletion. We never need to backtrack up the tree—all potential underflow is fixed during descent. This simplifies implementation and improves performance.
Root Collapsing: Reducing Tree Height
When deletion causes the root to become empty (0 keys), the tree height must decrease. This is the inverse of root splitting.
Condition: Root has 0 keys and 1 child (can only happen after a merge that consumed the root's last separator).
Action: The single child becomes the new root.
COLLAPSE-ROOT-IF-NEEDED(tree):
IF tree.root.numKeys = 0 AND NOT tree.root.isLeaf:
oldRoot ← tree.root
tree.root ← tree.root.children[0] // Sole child becomes root
FREE-NODE(oldRoot)
tree.height ← tree.height - 1
Example:
Before (delete 50, causes merge, root has 1 key consumed):
[50] ← Root with 1 key
/
[30] [70]
After merge of root's children:
[] ← Root empty!
|
[30, 50, 70] ← Merged child
After collapse:
[30, 50, 70] ← This is now the root
Now delete 50:
[30, 70] ← Tree height reduced by 1
Edge Case: Deleting the Last Key
When the tree contains a single key in a single-node root:
Tree: [42] ← Only key in tree
Delete 42:
- Root becomes empty
- Root is a leaf (no children)
- Tree is empty
EMPTY-TREE(tree):
IF tree.root.numKeys = 0 AND tree.root.isLeaf:
tree.root ← NULL
// Or keep empty root for future insertions
Some implementations maintain an empty root node rather than nullifying it, avoiding allocation on the next insert.
Just as root splits (height increase) are rare, root collapses (height decrease) are equally rare. For a tree with t=100, the root must be reduced from 1 key to 0 through deletion—requiring approximately half the tree's content to be deleted to trigger a height reduction.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.