Execution Engine Part 6 of 7
Part 6 · Statistics & planning

Guessing row counts, picking a plan

Parts 3–5 gave us several ways to do every job: three join algorithms, scan or index, hash or sort aggregation. Which one is right depends on how many rows each operator will see, and the planner has to decide before running anything. This part covers how it estimates those counts, how it prices a plan, and how it searches for the cheapest join order.

Here is a three-table query over the generated dataset:

SELECT c.city, sum(o.qty * p.price)
FROM customers c, orders o, products p
WHERE o.customer_id = c.id AND o.product_id = p.id AND c.city = 'Seattle'
GROUP BY c.city

The SQL says nothing about how to run it. We could join customers to orders first, or orders to products first. Each join could be a hash join (which side builds?), a nested loop, or an index nested-loop join, since orders.customer_id and products.id are indexed. The Seattle filter could narrow customers before the join or be applied after. With three tables there are a dozen reasonable plans. With eight tables there are millions. Their costs can differ by orders of magnitude.

A cost-based planner has three parts:

  1. a cardinality estimator that guesses how many rows each operator outputs,
  2. a cost model that turns those row counts into a price for each operator,
  3. a search over plans that finds the cheapest one without pricing them all.

The search is the elegant part and the cost model is the crude part. Almost all of the trouble comes from the estimator.

ANALYZE: what we know about a column

The estimator can't look at the data while planning. That would mean running the query to plan the query. Instead, an ANALYZE pass reads each table once, ahead of time, and keeps a small summary per column:

function analyze(table)
  for each column c
    values  = column c of every row
    nonNull = values without nulls, sorted
    stats[c] = {
      rows:  len(values),
      nulls: len(values) - len(nonNull),
      ndv:   count distinct nonNull,             -- number of distinct values
      min:   first(nonNull),  max: last(nonNull),
      hist:  equiDepth(nonNull, 8)                -- see below
    }

Real databases sample instead of reading everything. Postgres reads 30,000 rows by default and estimates ndv from the sample, which is a hard problem on its own. Our tables are tiny, so the lesson reads them all.

With only rows and ndv, the natural guess for orders.customer_id = 7 is "every customer has the same number of orders": 5000 / 200 = 25. But the generator is skewed: half of all orders go to customers 1–20. Customer 7 actually has 126 orders, five times the estimate. Customer 150 has 11. A single ndv can't describe a skewed column. A histogram can.

Histograms

A histogram splits the value range into buckets and records how many rows fall in each. Inside a bucket, the estimator assumes values are spread evenly. There are two ways to pick the bucket boundaries:

function equiDepth(sorted, k)
  buckets = []
  for b in 0 .. k-1
    end = index of the ((b+1)/k)-th row, extended so one value never spans two buckets
    buckets.append({ hi: sorted[end], count: rows in this bucket, ndv: distinct in this bucket })
  return buckets

function estimateLessOrEqual(hist, x)              -- col <= x
  sum of count for buckets entirely <= x
  + count * (x - bucket.lo + 1) / (bucket.hi - bucket.lo + 1)   -- linear slice of x's bucket

function estimateEquals(hist, x)                   -- col = x
  b = bucket containing x
  return b.count / b.ndv                           -- values in a bucket are equally common

Slide the predicate around below. The grey bars are the real per-value counts. The teal blocks are what the estimator thinks the distribution looks like: one flat block per bucket. Try orders.customer_id = x with no histogram, then equi-width, then equi-depth, at x = 7 and x = 150. Then try orders.day, which is uniform: every method does well there.

The equi-depth histogram spends four of its eight buckets on customers 1–19, because that's where half the rows are. Its estimate for customer 7 is 143 against an actual 126. The equi-width histogram puts customers 1–25 in a single bucket, mixing the hot range with the start of the cold one, so it's off in both directions.

Selectivity: combining predicates

A selectivity is the fraction of rows a predicate keeps. Estimated output = input rows × selectivity. The lesson code uses these rules, which are close to what every textbook planner starts with:

predicateselectivity
col = const1 / ndv × (non-null fraction)
col < / <= / > / >= consthistogram fraction, interpolated inside a bucket
col IS NULLnulls / rows
a AND bs(a) × s(b)
a OR bs(a) + s(b) − s(a)·s(b)
NOT a1 − s(a)
anything else1/3 (a shrug, kept from System R)

The AND rule is the independence assumption: knowing one predicate is true tells you nothing about the other. In our data it holds, because city and age were generated independently. In real data it often doesn't. city = 'Austin' AND state = 'TX' has selectivity s(city), not s(city) × s(state), so the product underestimates by a factor of 1/s(state), maybe 50×. Correlated predicates are the main reason estimates go wrong, and the reason Postgres added CREATE STATISTICS for column groups.

Join cardinality

For an equi-join L.a = R.b, the standard estimate is:

|L ⋈ R| = |L| · |R| / max(ndv(L.a), ndv(R.b))

The reasoning: assume that every value on the side with fewer distinct values appears on the other side too (the containment assumption), and that values are evenly spread. Then each of the |L| rows matches |R| / ndv(R.b) rows on average, and the max picks the side whose values are the subset. For a foreign-key join like orders.customer_id = customers.id, this gives exactly |orders|, which is right.

Now add the filter. Seattle is 1 of 8 cities, so the estimator guesses 200/8 = 25 Seattle customers. The generator makes Seattle rare, and there are only 10. The join estimate is 5000 · 25 / 200 = 625 orders, and the actual count is 622. The estimate is nearly perfect, by accident: the customer count is 2.5× too high, but those 10 customers include some of the hot ones from 1–20, so they have more orders than average. The two errors cancel. In general they don't, and they multiply instead.

The cost model

Costs are in made-up units, roughly "rows touched". The only thing that matters is that they rank plans in the right order:

cost(Scan t)               = rows(t)
cost(Filter)               = rows(input)
cost(HashJoin)             = 2 · rows(build) + rows(probe)       -- build side = right input
cost(NLJoin)               = rows(outer) · rows(inner)
cost(IndexNLJoin)          = rows(outer) · (log2(rows(inner)) + matches / rows(outer))
cost(Sort)                 = n · log2(n)
cost(HashAggregate)        = rows(input)
total(plan)                = sum over every operator

Real cost models separate CPU from I/O, sequential from random reads, and account for memory (a hash join that spills costs far more). They are still rough guesses, and Leis et al. found that once the cardinalities are right, even a very simple cost model picks good plans. The cardinalities are what matter.

Searching: dynamic programming over subsets

With n tables there are n! left-deep join orders (each join's right input is a base table), and far more if you allow bushy trees. Pricing each one separately doesn't scale. In 1979, System R observed that the problem has optimal substructure: the best plan joining {c, o, p} must contain the best plan for whichever two-table subset it joins first. So compute the best plan for every subset, smallest first, and reuse those results:

function bestPlan(relations, predicates)
  best = {}
  for each r in relations                        -- level 1: access paths
    best[{r}] = cheapest of (Scan r + filters, IndexScan r if an index fits a filter)

  for size in 2 .. n
    for each subset S of relations with |S| = size
      for each r in S                            -- r joins last (left-deep)
        left = best[S - {r}]
        if left is null then continue
        if no predicate connects S - {r} and r then continue   -- skip cross products
        for alg in [HashJoin, NLJoin, IndexNLJoin if r has an index on its join column]
          candidate = Join(alg, left, accessPath(r))
          if cost(candidate) < cost(best[S]) then best[S] = candidate
  return best[relations]

There are 2ⁿ subsets with up to n choices each, which is exponential but far better than n!. The usual practical limit is about 10–12 tables; past that, planners switch to greedy or genetic search. (Postgres's geqo_threshold defaults to 12.) Step through it below, then switch scenarios: the filters change the estimates, and the estimates change which plan wins.

Some things to notice:

Selinger's planner also tracked interesting orders. A plan that is more expensive but produces rows sorted on a useful column (for a later merge join, GROUP BY, or ORDER BY) is kept alongside the cheapest plan, because it may save a sort further up. Our lesson skips this, so the DP table holds one plan per subset. A real planner keeps one per (subset, sort order).

EXPLAIN: estimates next to reality

The integrated engine prints its chosen physical plan with an estimated row count on every node. The lesson demo runs the plan and prints the actual count next to it, like Postgres's EXPLAIN ANALYZE. The last step of the figure above shows the same thing for the chosen plan. Reading these side by side is the most useful debugging habit for slow queries: find the lowest node where estimate and actual diverge, because every operator above it was chosen on bad information.

Errors compound. If each estimate is off by 2× and a query has five joins, the top of the plan can be off by 2⁵ = 32×, in either direction. Leis et al. measured this on a real dataset (IMDB) with the planners of several major databases. The errors routinely reached multiple orders of magnitude, they grew exponentially with the number of joins, and they caused nearly all the bad plans. Planners are only as good as their row counts.

sqlglot's optimizer is rule-based. It qualifies columns, pushes predicates down, merges subqueries, simplifies expressions, and (in optimize_joins) reorders joins only so that each join's condition refers to tables already joined, avoiding cross joins. There are no statistics and no costs, because it's a SQL-to-SQL transpiler whose output usually goes to a database that does its own planning. Its executor's Plan takes the optimized query as-is, so the join order you write is the join order you get.

go/lesson06/planner.go · clojure/src/lesson06/planner.clj (ns lesson06.planner): Analyze (column stats + equi-depth histograms), the selectivity and join-cardinality estimator, the cost model, the DP join orderer, and an EXPLAIN that runs the plan and prints estimated vs actual rows per node. Run with go run ./cmd/lesson06 or clojure -M -m lesson06.planner. In the integrated engine: \stats orders and \explain on.

Or run it right here: open the terminal (the Go demo, compiled to WebAssembly).

Write the estimator and the search: equality and range selectivity from stats and histograms, equi-join cardinality, and the DP over subsets that picks a join order. The exercise track for this part (eq-selectivity, range-selectivity, join-cardinality, join-order-dp) strips those function bodies out of the lesson and leaves the tests. Fill them in until the tests pass:

cd go && go test ./exercises/lesson06/
cd clojure && clojure -M:ex -d ex -n exercises.lesson06.planner-test

Hints and the full list are in the exercises page.

A planner searches plans with dynamic programming over subsets and prices them with a crude cost model. Both depend entirely on cardinality estimates, which come from per-column summaries (ndv, min/max, equi-depth histograms) combined under independence and containment assumptions. When a plan is bad, check the estimates before the cost model.