droid
Letting the Cost Model Choose
Joins and Relations · Stage 8 of 13

Letting the Cost Model Choose

Estimate the join's output, price each algorithm, keep the cheapest

When every table has statistics, estimate each join's rows from the distinct counts, price the nested loop, index nested loop, hash and merge joins with fixed formulas, pick the cheapest with a fixed tie-break, and print rows= and cost= on every node.

Not run yet · 8 tests
Concepts in this stage
  • An equality join keeps about L × R / max(ndv) rows
  • Each algorithm has a cost formula in page reads and rows
  • A small outer side favours the index; two large inputs favour hashing; sorted inputs favour merging
  • No statistics, no choice: without them the planner keeps the nested loop
  • Stale statistics can pick a worse plan, never wrong rows
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
LexerParserValidator
Answering a query
Planner
Executor
Keeping changes safe
TransactionsWrite-ahead log
Undo log
Lock manager
Read views
Storing rows
Row formatSlotted pageB+Tree
Reaching the disk
PagerDatabase file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

Why this stage exists

You now have four ways to join, and each is the best one somewhere. An index nested loop is unbeatable for five users' orders and hopeless for all of them without an index. A hash join is the opposite. A merge join wins when both sides already come sorted. Forcing one with --join was fine for learning each; a real query needs the planner to pick.

The cost-model stage priced scans. This stage prices joins the same way: estimate how many rows each node produces, put a number on each algorithm, keep the cheapest. From here --join auto, the default, does that, as long as every table has statistics. Without them it keeps the nested loop, as the cost-model stage kept its rule plans.

How many rows does a join produce?

For users join orders on u.id = o.user_id, each order matches about one user. In general, an equality between two columns keeps about L × R / max(ndv_l, ndv_r) rows: 1000 users and 2000 orders with 1000 distinct user_id give 2000. The idea is that the column with fewer distinct values has its values all present in the other, so each row on one side meets R / ndv rows on the other. Any other condition keeps a third, as for a WHERE. A cross join keeps everything, and a LEFT JOIN at least its left rows.

An index nested loop's right child is a lookup, which estimates the rows of one value, E = floor(R / ndv) (with ndv = R for the table's key), and prints its cost for that one run. The join still uses the right table's full R, without the lookup, so the estimate is the same whatever the algorithm.

What each algorithm costs

a (4 rows, 4 distinct k) join b (6 rows, 3 distinct k), one leaf each
ran
NestedLoopJoin (on: a.k = b.k)
↑ rows=4 cost=1
SeqScan (table=a)
↑ rows=6 cost=1
SeqScan (table=b)
Nested loop 1 + 4 × 1 = 5. Index nested loop 1 + 4 × 3 = 13. Hash 1 + 1 + 2 × 6 + 4 = 18. Merge, sorting both, 1 + 1 + 4 + 6 + 4 + 6 = 22.
JoinCost
nested loopC(L) + rows(L) × C(R): the right side, once per left row
index nested loopC(L) + rows(L) × lookup: one lookup per left row
hashC(L) + C(R) + 2 × rows(R) + rows(L): each side once, building costs twice per row
mergeC(L) + C(R) + rows(L) + rows(R), plus rows(x) for each side that must be sorted

On tiny tables the nested loop wins: reading a one-page table four times is nothing. On large ones the picture changes:

  • five users' orders, with an index on user_id: five lookups beat reading 2000 wide orders, so the index nested loop wins;
  • every user's orders, no index: the hash join reads each table once;
  • users joined to a table keyed by user id: both sides already arrive in key order, and the merge join is one pass over each.

Ties go to the simpler algorithm, in the order nested loop, index nested loop, hash, merge, so every implementation prints the same plan. Ties are not rare: users joined to orders with no index costs the same by hash and by merge when users already arrives in key order and only orders needs a Sort, and the hash join is taken.

The algorithm in words

1

Estimating a join

How many rows come out.

  1. 1.Take the estimated rows of the two children, L and R, which already include the conditions pushed below the join. Under an index nested loop, R is the right table's rows under its own conditions, without the lookup: the lookup's equality is the join condition, counted once.
  2. 2.For each conjunct of the condition that is a column = a column, both with statistics, multiply by 1 / max(ndv of the two columns). For any other conjunct, multiply by 1/3.
  3. 3.rows = floor of L × R times those factors. With no condition, L × R. For a LEFT JOIN, at least L.
2

Choosing the algorithm

Price every algorithm that can be used.

  1. 1.Nested loop: C(L) + rows(L) × C(R).
  2. 2.Index nested loop: C(L) + rows(L) × the cost of one lookup, priced as the cost-model stage prices that scan for one value.
  3. 3.Hash join: C(L) + C(R) + 2 × rows(R) + rows(L), plus C(L) + C(R) again if rows(R) is above --join-memory.
  4. 4.Merge join: C(L) + C(R) + rows(L) + rows(R), plus rows(x) for each input x that needs a Sort.
  5. 5.Keep the cheapest; on a tie take the first of nested loop, index nested loop, hash, merge.

Output contract for this stage

These are the exact lines the tests check. The full definition is on the output contract page, section 73.

InputOutput
explain select * from a join b on a.k = b.k;, both analysedNestedLoopJoin (on: a.k = b.k) (rows=6 cost=5) over SeqScan (table=a) (rows=4 cost=1) and SeqScan (table=b) (rows=6 cost=1)
the same with --join hashHashJoin (on: a.k = b.k) (rows=6 cost=18)
the same with --join inl, index bk on b (k)IndexNestedLoopJoin (on: a.k = b.k) (rows=6 cost=13) over IndexScan (table=b, index=bk, key=a.k) (rows=2 cost=3)
explain select * from a, b;NestedLoopJoin (rows=24 cost=5)

What you should see when done

shell
$droid --db choose.db
Welcome to droid!
>create table a (id int primary key, k int);
Create table statement executed successfully
>create table b (id int primary key, k int);
Create table statement executed successfully
>insert into a (id, k) values (1, 1);
Insert statement executed successfully (1 rows)
>insert into b (id, k) values (1, 1);
Insert statement executed successfully (1 rows)
>analyze a;
Analyze statement executed successfully
>analyze b;
Analyze statement executed successfully
>explain select * from a join b on a.k = b.k;
[PLAN] select * from a join b on a.k = b.k
[PLAN] └── NestedLoopJoin (on: a.k = b.k) (rows=1 cost=2)
[PLAN] ├── SeqScan (table=a) (rows=1 cost=1)
[PLAN] └── SeqScan (table=b) (rows=1 cost=1)
>.exit
exiting.. good bye!

Common bugs in this stage

  • Choosing by cost when a table has no statistics. Without them auto is the nested loop, and nothing prints an estimate.
  • Integer division too early. Multiply everything, then floor once.
  • Pricing an algorithm that cannot be used. A hash join on < is not a candidate.
  • Breaking ties differently. The order is nested loop, index nested loop, hash, merge.
  • Counting the merge join's sort twice. The Sort node prints its child's cost; the join adds rows(x).

Language notes

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

  • C: keep estimates as 64-bit integers and selectivities as numerator/denominator pairs; multiply both, divide once.
  • C++: a small Fraction struct with operator* keeps the arithmetic exact.
  • Rust: u128 numerators and denominators avoid overflow for products of large row counts.
  • Zig: u128 and @divFloor at the end; std.math.mul catches an overflow if you go wrong.

Checklist

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

  • Each algorithm's cost, computed by hand on two one-leaf tables, and auto takes the cheapest
    join-cost-exact
  • The join's rows: the product divided by the larger distinct count; a cross join is the product; LEFT keeps the left rows
    join-rows-estimate
  • A selective filter on users and an index on orders(user_id): the index nested loop wins
    small-outer-picks-index
  • Two large tables and no index: the hash join wins
    large-unindexed-picks-hash
  • Two inputs already in key order: the merge join wins, with no sort
    ordered-inputs-pick-merge
  • A condition with no equality leaves only the nested loop
    non-equi-picks-nested-loop
  • Drop the index the plan used, and the same query plans again, with the same rows
    drop-index-replans
  • Without statistics, or with stale ones, the plan may differ but the rows never do
    stale-stats-still-correct