droid
Selectivity, Cost and Choosing a Scan
Indexes and the Optimizer · Stage 8 of 11

Selectivity, Cost and Choosing a Scan

Estimate how many rows each plan keeps, price it in page reads, and take the cheapest

On a table with statistics, estimate the rows of every plan node from distinct counts and min and max, price each candidate scan with integer formulas, choose the cheapest, and print rows= and cost= on every node; on a table without statistics, keep the rules.

Not run yet · 5 tests
Concepts in this stage
  • Selectivity: the fraction of rows a predicate keeps, guessed from a few numbers per column
  • Independent predicates multiply, which is often wrong and always simple
  • Cost counts page reads: a full scan reads each page once, an index pays a table descent per row it finds
  • An index loses when it finds too many rows; a covering index loses much later
  • The planner believes its statistics, however stale: a wrong estimate makes a wrong plan
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

Every plan so far was chosen by rule: if an index can serve the WHERE, use it. Here is a table where that rule is a disaster. A thousand rows, a column v with two values, 'a' and 'b', and an index on v:

select count(pad) from w where v = 'a';

The index finds 500 entries, and each one costs a descent of the table: hundreds of page reads, many of the same pages again and again. A full scan reads each of the table's pages once. The rule picked the slow plan, and it will keep picking it.

A cost-based optimizer asks two questions instead: how many rows will each plan touch, and what will that cost. The statistics of the previous stage answer the first; a few integer formulas answer the second.

How many rows: selectivity

The selectivity of a predicate is the fraction of rows it keeps. With statistics it is a guess, but a good one:

PredicateGuessOver 1000 rows, ages 0–99, ten cities
city = 'c1'one in ndv1/10 → 100
age < 5the share of the range below 55/100 → 50
age >= 90the share at or above 9010/100 → 100
age < 5 and city = 'c1'the product5/100 × 1/10 → 5
city > 'c5'no good guess for strings1/3 → 333

Multiplying assumes the two predicates are independent: that knowing a person's city tells you nothing about their age. Real data breaks that all the time (city and postcode, make and model), and real optimizers misestimate because of it. It is still what almost every optimizer does by default, because anything better needs statistics on column pairs.

What it costs: page reads

ScanCostWhy
SeqScanPevery page of the table, once
IndexScan of an indexDi + E × Dtone descent of the index, then one of the table per row found
IndexOnlyScanDi + ceil(E / 100)one descent, then entries side by side, about a hundred to a leaf
IndexScan of the table's keyDt + ceil(E × P / R)one descent, then the pages those rows fill

Di and Dt are the depths of the index and the table, P and R the table's pages and rows, E the estimated rows. With them the two-value table is no contest:

IndexScan, as the rule chose: cost 2 + 500 × 4 = 2002
Aggregate (aggs=[COUNT(pad)])
↑ 500 rows
IndexScan (table=w, index=wv, key='a')
500 table descents
SeqScan, as the cost chooses: cost 110
Aggregate (aggs=[COUNT(pad)])
↑ 500 rows
Filter (v = 'a')
↑ 1000 rows
ran
SeqScan (table=w)
each page once
Depths are illustrative, and the Project above each Aggregate is left out. The index plan reads far fewer rows and far more pages.

Change the query to select count(v) and the index covers it. Now the index-only cost is about 2 + 5, far below 110, and the index wins again. Covering is not a detail: it moves the tipping point by a factor of a hundred.

On a tiny table the opposite surprise happens. Four rows in one leaf: a full scan costs 1, any index costs at least 2. So where name = 'bob' is a SeqScan even with an index on name, and that is right.

Stale statistics are believed

ANALYZE runs only when asked, so the planner may be working from an old picture. Take 1000 distinct values, ANALYZE, then update t set v = 'x'. The statistics still say one row in a thousand has any given value, so where v = 'x' is estimated at 1 row and planned as an IndexScan. It finds 1000, with 1000 table descents. Nothing is wrong with the planner; its information is. ANALYZE again and the same query becomes a full scan. The next stage, EXPLAIN ANALYZE, puts the estimate and the truth side by side so you can see this happen.

No statistics, no change

A table that has never been analysed is planned exactly as before, by the rules, and explain prints no estimates. That keeps every earlier stage's plans as they were, and makes the cost model something you turn on, table by table, by running ANALYZE.

The algorithm in words

1

Estimate rows

Every conjunct gets a fraction; a node's rows are the table's rows times the fractions of every conjunct at or below it.

  1. 1.c = v: 1 / ndv of c.
  2. 2.c < v on an INT column: (v − min) / (max − min + 1), and likewise for <=, > and >=, clamped to 0..1.
  3. 3.c is null: nulls / rows; c is not null: the rest.
  4. 4.Anything else: one third.
  5. 5.Multiply the fractions exactly, multiply by the table's rows, and round down once at the end.
2

Price each scan

Costs are page reads, in integers.

  1. 1.SeqScan: the table's pages.
  2. 2.IndexScan of an index: the index's depth, plus the estimated rows times the table's depth, since each row found is one more descent of the table.
  3. 3.IndexOnlyScan: the index's depth plus the estimated rows divided by 100, rounded up: a leaf holds many entries.
  4. 4.IndexScan of the table's own tree: its depth, plus the pages the estimated rows fill.
  5. 5.Every other node costs what its child costs; Project and Sort keep their child's rows.
3

Choose

Rules first where they cannot be wrong, cost everywhere else.

  1. 1.A table without statistics: plan by the rules of the earlier stages, and print no estimates.
  2. 2.A key bound on the table's key: the table's IndexScan, as always.
  3. 3.Otherwise price a SeqScan and the scan each usable index would give, index-only where it covers the statement, and take the cheapest; ties go to the SeqScan.
  4. 4.Print (rows=E cost=C) after every node.

Output contract for this stage

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

InputOutput
explain select * from t where v = 'c7'; (1000 distinct, analysed)[PLAN] └── IndexScan (table=t, index=by_v, key='c7') (rows=1 cost=<C>)
explain select * from w where v = 'a'; (two values, analysed)Filter (v = 'a') (rows=500 cost=<P>) over SeqScan (table=w) (rows=1000 cost=<P>)
explain select * from a where age < 5 and city = 'c1';Filter ((age < 5) AND (city = 'c1')) (rows=5 cost=<P>)
explain select * from s where name = 'bob'; (four rows, one leaf)Filter (name = 'bob') (rows=1 cost=1) over SeqScan (table=s) (rows=4 cost=1)
explain select * from s where id = 2;IndexScan (table=s, key=2) (rows=1 cost=2)
any table never analysedthe plan of the earlier stages, with no (rows= cost=)

There is no new syntax and no new code.

What you should see when done

shell
$droid --db cost.db
Welcome to droid!
>create table s (id int, name varchar(10), n int);
Create table statement executed successfully
>create index sn on s (name);
Create index statement executed successfully
>insert into s (id, name, n) values (1, 'ada', 1);
Insert statement executed successfully (1 rows)
>insert into s (id, name, n) values (2, 'bob', 2);
Insert statement executed successfully (1 rows)
>explain select * from s where name = 'bob';
[PLAN] select * from s where name = 'bob'
[PLAN] └── IndexScan (table=s, index=sn, key='bob')
>analyze s;
Analyze statement executed successfully
>explain select * from s where name = 'bob';
[PLAN] select * from s where name = 'bob'
[PLAN] └── Filter (name = 'bob') (rows=1 cost=1)
[PLAN] └── SeqScan (table=s) (rows=2 cost=1)
>.exit
exiting.. good bye!

Before ANALYZE the rule uses the index; after it, the planner knows the table is one page and reads it whole.

Common bugs in this stage

  • Rounding each factor. 5/100 × 1/10 × 1000 is 5; rounding 5/100 × 1000 to 50 first and then 50 × 1/10 happens to agree, but other inputs do not. Multiply exact fractions and round down once.
  • Floating point. Two languages can print different estimates from the same doubles. Use integers or exact fractions.
  • Forgetting the table descent per row. Without E × Dt an index always looks cheaper than a scan, and the stage teaches nothing.
  • Choosing by cost on a table without statistics. The earlier stages' plans must not change.
  • Clamping too late. age < 500 over ages 0–99 is a selectivity of 1, not 5.

Language notes

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

  • C: keep a fraction as two uint64_t (numerator and denominator); a few conjuncts over a thousand rows stay far from overflow.
  • C++: the same pair, or std::ratio-like helpers; avoid double.
  • Rust: a small struct Frac { num: u128, den: u128 } with Mul keeps the code readable.
  • Zig: u128 numerators and denominators, multiplied with std.math.mul to catch overflow in debug builds.

Checklist

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

  • One row in a thousand goes through the index; half the table is read by a full scan, unless the index covers it
    selective-and-unselective
  • Stale statistics keep a bad plan; ANALYZE fixes it; a key bound reads the key either way
    plan-flips-with-data
  • explain prints rows= and cost= on every node of an analysed table, and the cheaper scan
    estimates-and-choice
  • Ranges are estimated from min and max, equalities from distinct counts, and AND multiplies them
    selectivity
  • On one leaf, every cost is computed by hand, and the full scan wins
    hand-computed-cost