droid
B+Tree Deletion: Borrow and Merge
Memory and Space · Stage 6 of 8

B+Tree Deletion: Borrow and Merge

The half of the B+Tree most tutorials skip: keeping nodes full as rows leave, and giving pages back

After a DELETE, or an UPDATE that makes rows smaller, keep every node other than the root at least a quarter full. A node below that borrows from a sibling when the two do not fit in one node, and merges with it when they do; a merge frees a page and takes a key from the parent, which may need fixing in turn. A root left with one child takes that child's place, and the tree gets shorter.

Not run yet · 10 tests
Concepts in this stage
  • Minimum fill: a quarter of a leaf's bytes, at least one key in an internal node
  • Borrow: two siblings divide their cells again, as a split would
  • Merge: two siblings that fit in one node become one, and a page goes on the free list
  • A merge removes a separator, so the parent can fall below minimum: the fix walks up
  • Root collapse: the only way a B+Tree gets shorter, and the root still never moves
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
LexerParserValidator
Answering a query
PlannerExecutor
Keeping changes safe
Transactions
Write-ahead log
Undo log
Lock manager
Read views
Storing rows
Row formatSlotted page
B+Tree
Reaching the disk
Pager
Database file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

Why this stage exists

In The Core Engine, DELETE removed a cell and stopped. A leaf could end up empty, a separator could name a key that no longer exists, and the tree was still correct: every search found the right rows. What it was not was small. Insert a million rows and delete all but ten, and the tree still has every page it ever had, nearly all of them empty, and a scan reads them all.

Real B+Trees fix this as they go. When a node gets too empty, it either takes cells from a neighbour or merges with it, and a merge gives a page back. Now that the file has a free list, those pages have somewhere to go: the next split takes them before the file grows. This is the half of the B+Tree most tutorials skip, because it has more cases than inserting. Each case is small, though, and they all follow from one rule: every node except the root stays at least a quarter full.

What changes: a minimum fill

A leaf is below minimum when its used bytes, each cell plus its 4-byte slot, are fewer than 1019, a quarter of the 4076 bytes an empty leaf has free. An internal node is below minimum when it has no key left, only one child. The root has no minimum: it may be an empty leaf, or an internal node with a single key.

A quarter, not a half, is on purpose. With a half, a leaf just over the line merges after one delete and splits after the next insert, and a workload that alternates the two keeps doing both. A quarter leaves room between the two thresholds, so a leaf that was just split, about half full, is far from merging again.

When a DELETE leaves a node below minimum, it has to be fixed with one sibling: the node just before it under the same parent, or, for the first child, the node just after it. What happens depends on whether the two fit in one node.

Borrow: the two do not fit

When the two nodes together would not fit in one page, their cells are divided again, exactly as a split divides them: by bytes for leaves, with the separator moving down and one key moving up for internal nodes. Both nodes keep their pages, nothing is freed, and the separator between them becomes the first key of the right leaf.

Take rows of 502 bytes, so each cell uses 510 bytes with its slot: two are the minimum, seven fit in a leaf and eight do not. Rows 1 to 11 make a leaf of four and a leaf of seven. Deleting 1, 2 and 3 leaves the first with one row, 510 bytes, below minimum:

After deleting 1, 2 and 3: page 3 is below minimum
internal · page 2
[5]
leaf · page 3 · below minimum
[4]
next → page 4
leaf · page 4
[5,6,7,8,9,10,11]
next → none
Borrow: the eight cells divide into four and four
internal · page 2 · changed
[8]
leaf · page 3 · changed
[4,5,6,7]
next → page 4
leaf · page 4 · changed
[8,9,10,11]
next → none
One plus seven is eight cells, 4080 bytes, too many for one leaf. Divided by bytes, as a split would, they make two leaves of four, and the separator becomes 8. Still two leaves, and no page freed.

Merge: the two fit

When the two nodes fit in one, they become one. The left node keeps its page and takes the right one's cells; the right node's page goes on the free list; the separator between them, and the right node's entry, leave the parent. The left leaf's link becomes whatever the right leaf's was, so a scan still visits every leaf.

Rows 1 to 12, after deleting 1, 2 and 3
internal · page 2
[5,9]
leaf · page 3 · below minimum
[4]
next → page 4
leaf · page 4
[5,6,7,8]
next → page 5
leaf · page 5
[9,10,11,12]
next → none
Merge: page 3 takes page 4's rows, page 4 is freed
internal · page 2 · changed
[9]
leaf · page 3 · changed
[4,5,6,7,8]
next → page 5
leaf · page 5
[9,10,11,12]
next → none
One plus four is five cells, 2550 bytes, which fit. Separator 5 leaves the root, page 3 now links to page 5, and db freelist shows chain=[4]. The next leaf split takes page 4 back.

A merge takes a key away from the parent. If that leaves the parent with no key, the parent is below minimum, and it is fixed the same way with its own sibling, one level up: internal nodes merge by pulling the separator down between their keys, or borrow by dividing their keys as an internal split does. This can go on up to the root.

Root collapse: the tree gets shorter

The root has no minimum, but an internal root with no key has only one child, and a level that does nothing. When that happens, the child's contents move into the root's page and the child's page is freed. The root keeps its page, as it does when it splits, so the catalog never changes; it is still the only node marked as root, and the child's children get the root's page as their parent. When the child was a leaf, the root becomes a leaf again.

Rows 1 to 8, after deleting 1, 2 and 3
internal · page 2
[5]
leaf · page 3 · below minimum
[4]
next → page 4
leaf · page 4
[5,6,7,8]
next → none
Merge, then collapse: the root is a leaf again
leaf · page 2 · changed
[4,5,6,7,8]
next → none
Leaves 3 and 4 merge into page 3, and page 4 is freed. That leaves the root one child, so the root takes page 3's rows and page 3 is freed too: db freelist shows chain=[3,4]. Depth goes from 2 to 1, and the catalog still says root=2.

Splitting is the only way a tree grows taller, and collapsing the only way it gets shorter. Both happen at the root, which is why every leaf is always at the same depth.

What stays the same

A separator is still a bound, not a copy of a key. Deleting a leaf's first key does not change the separator above it, as long as the leaf stays above minimum: every key on its left is still smaller and every key on its right still at least as large. Only a borrow sets a separator, and only a merge removes one.

A DELETE of many rows behaves as if the rows were removed one at a time, in ascending key order, with any node fixed after each. So does an UPDATE: a row that gets smaller can leave its leaf below minimum, and it is fixed the same way. The catalog is a B+Tree like any other, so DROP TABLE removing its row follows the same rules.

Real-world numbers. SQLite rebalances a page after a delete, sharing cells among up to three siblings at once. PostgreSQL's B-Tree indexes never merge leaves that still hold rows: a page is only removed once it is completely empty, and space is otherwise left for later inserts and VACUUM. The quarter used here keeps the cases few and the tests exact.

The algorithm in words

1

Is a node below minimum?

The rule every DELETE and every shrinking UPDATE checks after each row.

  1. 1.A leaf is below minimum when its used bytes, each cell's length plus 4 for its slot, are less than 1019: a quarter of the 4076 bytes an empty leaf has free.
  2. 2.An internal node is below minimum when it has no key left, so only one child.
  3. 3.The root has no minimum. A root leaf may be empty, and a root internal node needs only one key.
2

Fix a node below minimum

Pick a sibling, then merge if the two fit in one node and borrow if they do not.

  1. 1.The sibling is the node just before it under the same parent. A parent's first child has none before it, so it uses the node just after it.
  2. 2.Two leaves fit in one when their used bytes together are at most 4076. Two internal nodes fit when their keys and the separator between them make at most 3 keys.
  3. 3.Merge: the left node of the two keeps its page and takes the right one's cells, or, for internal nodes, the separator and then the right one's keys and children. The left leaf's link becomes the right leaf's. Free the right node's page, and remove its separator and its entry from the parent.
  4. 4.Borrow: divide the two nodes' cells again as a leaf split divides them, by bytes; or their keys, with the separator between them, as an internal split does, one key moving up. The separator becomes the first key of the right leaf, or the key that moved up. No page is allocated or freed.
  5. 5.Children that moved to another node get that node as their parent.
  6. 6.After a merge, the parent has one key fewer. If it is now below minimum, fix it the same way, one level up.
3

Collapse the root

The only step that makes the tree shorter.

  1. 1.When the root is an internal node left with no key, it has one child. Copy that child's contents into the root's page and free the child's page.
  2. 2.The root keeps its page, is still the only node marked as root, and the child's children now have the root's page as their parent. If the child was a leaf, the root is a leaf again, with no next leaf.
  3. 3.Invariant: the recorded root never changes, and every leaf is still at the same depth.
4

Many rows in one statement

A DELETE or UPDATE that touches several rows behaves as if it touched them one at a time.

  1. 1.Collect the rows the WHERE selects, then remove or update them in ascending key order.
  2. 2.After each one, fix any node it left below minimum, before going on to the next row.

Output contract for this stage

The full definition is on the output contract page, section 28. No command or message is new: what changes is the tree that btree structure, db freelist and the file show after a DELETE or an UPDATE.

RuleValue
Leaf below minimumused bytes (cells plus 4 per slot) below 1019
Internal node below minimumno key
Two leaves mergewhen their used bytes together are at most 4076
Two internal nodes mergewhen their keys and the separator make at most 3 keys
Siblingthe node before; for a first child, the node after
Mergethe left keeps its page; the right's page is freed
Borrowdivides as a split does; no page allocated or freed
Root with one childtakes the child's contents; the child's page is freed; the root never moves

What you should see when done

Rows 1 to 8 of 502 bytes, then three deleted:

droid session
Welcome to droid!
>btree structure users
[BTREE] Tree depth: 2
[BTREE] INTERNAL (page=2 keys=[5])
[BTREE] LEAF (page=3 cells=4 keys=[1,2,3,4])
[BTREE] LEAF (page=4 cells=4 keys=[5,6,7,8])
>delete from users where id < 4;
Delete statement executed successfully (3 rows)
[EXEC] rows_examined=3
>btree structure users
[BTREE] Tree depth: 1
[BTREE] LEAF (page=2 cells=5 keys=[4,5,6,7,8])
>db freelist
[DB] Free list: pages=2 chain=[3,4]
>catalog list
[CATALOG] Tables: 1
[CATALOG] users root=2 (id INT, name VARCHAR(1000), email VARCHAR(1000))
>.exit
exiting.. good bye!

Things to notice. Deleting 1 and 2 changed nothing but leaf 3: two rows are still the minimum. Deleting 3 left one row, so leaf 3 merged with leaf 4, and then the root had a single child and collapsed. The two freed pages are on the list, and the table's root is still page 2.

Common bugs in this stage

  • Fixing only at the end of the statement. A DELETE of many rows must fix nodes after each row, in key order; fixing once at the end gives a different tree.
  • Losing the leaf chain. After a merge the left leaf must link to whatever the right leaf linked to. Forget it and a scan stops early, or walks into a free page.
  • Merging into the right node. The left node of the pair keeps its page and the right one is freed, whichever of the two was below minimum.
  • Moving the root. Collapsing by making the child the new root changes the root's page, and the catalog no longer finds the table. Copy the child into the root's page instead.
  • Forgetting the parents. Children that move during an internal merge, a borrow or a collapse need their parent field rewritten, or the next split up the tree updates the wrong node.
  • Reading a page after freeing it. Freeing zeroes the page. Read everything you need from the right node before it goes on the list.

Language notes

The page above is the same for every language. These are the only language-specific hints.

  • C: decode both siblings into plain arrays of cells, combine or divide them, and write each node back whole; patching a slotted page in place for a merge is where bugs hide.
  • C++: the same, with a std::vector of cells per node; reuse the split's dividing code for a borrow.
  • Rust: keep the path from the root as a Vec of (page, child index) while descending, then walk it backwards to fix parents; no parent pointers need to be trusted.
  • Zig: the same path as a fixed-size array is enough, as the tree is never more than a few levels deep.

Checklist

One line per thing the tests check. The tags name the test cases.

  • A leaf below minimum borrows from the leaf after it when the two do not fit in one
    borrow-from-right
  • A leaf that is not the first child borrows from the leaf before it
    borrow-from-left
  • A leaf below minimum merges with its sibling when the two fit in one; a separator leaves the parent
    merge-leaves
  • The page a merge frees is the next page a split takes
    merge-frees-page
  • A merge into the leaf before keeps the leaf chain whole; deleting a first key keeps its separator
    merge-fixes-leaf-links
  • A merge that leaves an internal node with no key merges it too
    merge-cascades-up
  • A root with one child collapses; the tree gets shorter and the root keeps its page
    root-collapse
  • An UPDATE that makes rows smaller fixes a leaf it leaves below minimum
    update-shrink-merges
  • Deleting every row leaves the root as an empty leaf and every other page free
    delete-everything
  • A thousand inserts, deletes and updates keep every invariant and every row
    delete-insert-churn