Internals · Query Enginecost modelselectivitycardinalitystatisticshistogram

Cost-Based Optimization

Statistics in, a number out: n_distinct, most-common values and histograms become a selectivity, a row estimate, and finally an I/O + CPU cost in units where a sequential page is 1.0 and a random page is 4.0. The arithmetic is simple; the inputs decide everything.

▶ InteractiveTry queriesInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    The planner has two candidate plans and must rank them without running either. It needs a number per plan that is comparable, cheap to compute, and roughly proportional to real work.

  2. Naive solution

    Count the rows each plan would touch by scanning the table at plan time.

  3. Why it breaks

    Scanning the table to decide whether to scan the table defeats the purpose. And "rows" is the wrong unit anyway: 1,000 rows on one page cost less than 10 rows on 10 random pages.

  4. Better idea

    Collect summary statistics once, in the background: row count, page count, distinct values per column, the most common values, a histogram. Estimate rows from those, then convert rows and pages into an abstract cost with a few constants.

  5. Internal mechanism

    Selectivity of col = v: frequency of v if it is in the MCV list, else (1 − Σ MCV freq) / (n_distinct − n_mcv). Range predicates use the histogram. Rows out = rows in × selectivity. Cost = pages × seq_page_cost or random_page_cost + rows × cpu_tuple_cost, summed up the tree. Joins multiply estimated inputs by join selectivity.

  6. Trade-offs

    Statistics go stale as data changes and are sampled, so they are approximate; correlated columns break the independence assumption; the constants are hardware guesses. The model is wrong in known ways and still far better than no model.

  7. Real database

    PostgreSQL: ANALYZE fills pg_statistic (view pg_stats), the planner reads reltuples/relpages and per-column n_distinct, most_common_vals, histogram_bounds; constants seq_page_cost = 1.0, random_page_cost = 4.0, cpu_tuple_cost = 0.01. This platform’s planner uses the same constants (plan.ts) and computes n_distinct and the top MCV on demand.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

Guessing well

The optimizer cannot afford to run each candidate plan, so it predicts. Its predictions rest on a small set of numbers the database collects about each table and column — how big, how many distinct values, which values are common — and on a handful of constants that say what a page read and a row comparison cost relative to each other.

The numbers are approximate on purpose; they only need to rank plans correctly, not to predict milliseconds.

Cardinality, selectivity, statistics

PostgreSQL implementation

Three words that the rest of the lesson depends on. Cardinality of a column is its number of distinct values — 2 for a boolean, 900 for a primary key over 900 rows. Selectivity of a predicate is the fraction of rows it keeps — 0.5 for is_staff = true on a 50/50 column, 1/900 for id = 42. Statistics are the stored summaries from which selectivity is estimated without reading the table: row count, page count, per-column n_distinct, null fraction, most-common values with frequencies, and a histogram of the remaining values.

For an equality predicate the estimate is a lookup or a division. If the value is in the MCV list, its stored frequency is the selectivity — exact, up to sampling. If not, the planner spreads the leftover probability mass evenly over the values it has not seen individually: (1 − Σ mcv_freq) / (n_distinct − n_mcv). For a range predicate the histogram gives the answer: price < 250 with bounds [1, 38, 79, 121, 163, 204, 241, 283, …] covers six full buckets plus (250−241)/(283−241) of the seventh, out of ten — about 0.62.

What ANALYZE stores for one column (PostgreSQL pg_stats, abbreviated)
tablename   | orders
attname     | merchant_id
null_frac   | 0
n_distinct  | 400                         -- or negative: -0.0004 = "0.04% of rows", scales with table
most_common_vals  | {12, 77, 88, 3, 250, 41, 199, 7, 315, 160}
most_common_freqs | {0.004, 0.0032, 0.0031, 0.0029, 0.0027, 0.0025, 0.0024, 0.0022, 0.0021, 0.0019}
histogram_bounds  | {1, 38, 79, 121, 163, 204, 241, 283, 322, 361, 400}
correlation | 0.02                        -- physical order vs value order: ~0 means random heap reads

selectivity(merchant_id = 137)  = (1 − 0.027) / (400 − 10) = 0.00249   → 2,495 rows of 1,000,000
selectivity(merchant_id = 12)   = 0.004  (in MCV list)                → 4,000 rows
selectivity(merchant_id < 250)  ≈ 6.2 buckets / 10                    → ~620,000 rows

I/O and CPU in cost units

Cost is measured in abstract units, and the constants are the whole model. seq_page_cost = 1.0 is the unit: one page read as part of a sequential sweep. random_page_cost = 4.0: one page fetched out of order — four times worse, the ratio of a disk seek to a streaming read. cpu_tuple_cost = 0.01: handling one row. cpu_index_tuple_cost = 0.005: visiting one index entry. cpu_operator_cost = 0.0025: evaluating one operator or function. This platform’s plan.ts uses exactly these values, which is why its EXPLAIN numbers look like PostgreSQL’s.

A sequential scan costs pages × 1.0 + rows × 0.01. An index scan costs height × 4.0 to descend, plus per matching row 0.005 for the index entry and a random heap page (4.0, discounted for rows that share a page — this engine uses 0.6) and 0.01 to handle the row. Both are linear in their input; the slopes differ by a factor of roughly 240 per row, and that is the whole reason a low-selectivity index loses.

The constants are hardware assumptions. On an SSD, or when the working set is cached, a random page is no slower than a sequential one and DBAs set random_page_cost = 1.1; the effect is that index scans win over a wider range of selectivities. The interactive on the previous lesson models a warm cache exactly this way.

This repo’s cost helpers (src/db/sql/plan.ts) — the same constants PostgreSQL ships
1export const SEQ_PAGE_COST = 1.0
2export const RANDOM_PAGE_COST = 4.0
3export const CPU_TUPLE_COST = 0.01
4export const CPU_INDEX_TUPLE_COST = 0.005
5export const PAGE_BYTES = 8192
6
7export const pagesFor = (rows: number, width: number) => Math.max(1, Math.ceil((rows * width) / PAGE_BYTES))
8
9export function seqScanCost(rows: number, width: number): number {
10 return pagesFor(rows, width) * SEQ_PAGE_COST + rows * CPU_TUPLE_COST
11}
12
13export function indexScanCost(matched: number, totalRows: number): number {
14 const descent = btreeHeight(totalRows) * RANDOM_PAGE_COST
15 const scanIndex = matched * CPU_INDEX_TUPLE_COST
16 const heap = matched * (RANDOM_PAGE_COST * 0.6 + CPU_TUPLE_COST) // rows sharing a page are discounted
17 return descent + scanIndex + heap
18}

Can a sequential scan be cheaper than an index scan?

Yes — often, and the arithmetic says exactly when. Take a 1,000,000-row table with 64-byte rows: 7,813 pages. The sequential scan costs 7,813 × 1.0 + 1,000,000 × 0.01 = 17,813. The index scan costs 3 × 4.0 for the descent plus 2.415 per matching row. Set them equal: 12 + 2.415 × m = 17,813 gives m ≈ 7,370 rows — 0.74% of the table. Below that, the index wins; above it, following the index means more random page reads than simply reading every page in order. WHERE status = 'paid' at 62% selectivity would cost 12 + 620,000 × 2.415 ≈ 1.5 million through the index against 17,813 for the scan: the index is 84× worse.

The break-even moves with row width (wider rows → more pages → the scan costs more → the index wins for longer), with random_page_cost (cached data → 1.0 → break-even around 3%), and with correlation — if the matching rows sit next to each other on disk (a timestamp column in insertion order), the heap reads are nearly sequential and the index wins far above 1%. The rule of thumb "an index pays off below 5–20%" is this calculation with typical inputs.

Break-even for a 1,000,000-row, 64-byte-row table (this platform’s constants)
seq scan       = 7,813 pages × 1.0 + 1,000,000 × 0.01           = 17,813
index scan(m)  = 3 × 4.0 + m × (0.005 + 4.0 × 0.6 + 0.01)         = 12 + 2.415 m

break-even     12 + 2.415 m = 17,813  →  m ≈ 7,371 rows  ≈ 0.74 %

selectivity    matching rows   index scan     seq scan     chosen
0.01 %              100             253.5      17,813      Index Scan
0.1  %            1,000           2,427        17,813      Index Scan
0.74 %            7,371          17,813        17,813      (tie)
5    %           50,000         120,762        17,813      Seq Scan
62   %          620,000       1,497,312        17,813      Seq Scan   (84× cheaper)

warm cache (random_page_cost = 1.0):  per-row 0.615  →  break-even ≈ 28,960 rows ≈ 2.9 %

Stale statistics → wrong plan

PostgreSQL implementation

The model is arithmetic on stored numbers, and the numbers age. Suppose the marketplace orders table was analysed when it had 49 merchants; it has since grown to 400 and a million rows, and autovacuum has not caught up. The planner estimates merchant_id = 137 at (1 − 0.027) / (49 − 10) = 2.5% — 25,000 rows — when the truth is 2,500. On a 400-byte row (48,829 pages, break-even 24,355 rows) that ten-fold error crosses the line: the planner chooses a sequential scan of 48,829 pages for a predicate an index would answer in about 6,000 cost units. Nothing errors. The query is simply eight times slower than it needs to be, on every execution, until ANALYZE orders runs.

This is the failure in the challenge Query Optimization: Finding the Actual Bottleneck describes as stale statistics, and it has one signature in EXPLAIN ANALYZE: rows=25000 estimated next to rows=2500 actual. The other classic cause of the same signature is correlated columnsWHERE city = 'Berlin' AND country = 'DE' is estimated as the product of two selectivities as if they were independent, undercounting by however correlated they are; CREATE STATISTICS on the column pair fixes that one.

Memory: work_mem, hash fits or spills

Cost is not only pages and rows; some operators need memory proportional to their input, and the planner models what happens when they do not get it. work_mem (4 MB by default in PostgreSQL, per operator, per query) bounds the build side of a hash join, the hash table of a HashAggregate, and the buffer of a Sort. A hash join whose build side fits is one pass over each input. One that does not is split into batches: both inputs are partitioned by hash into files on disk, then each batch is joined in memory — the inputs are written once and read once more, and the cost model adds that I/O. A sort that fits is in-memory quicksort; one that does not is an external merge sort, Sort Method: external merge Disk: 84,000kB in the plan.

Because the planner models the spill, raising work_mem can change the plan, not just the speed: a hash join that would have batched becomes cheaper than the merge join it lost to, and the plan flips. It also means the join-order search — dynamic programming over subsets of tables up to geqo_threshold, genetic search beyond — is comparing numbers that depend on a configuration knob. The optimizer is deterministic; its inputs are not.

Operators that need memory, and what happens without it
OperatorMemory neededFits in work_memExceeds work_mem
Hash Join (build)build side rows × widthone pass each side — goodbatches to disk: write + read both sides — spill
HashAggregateone entry per groupone pass — goodbatches by group hash — spill
Sortall input rowsquicksort in memory — goodexternal merge sort, log(batches) passes — spill
Nested Loopone outer rowalways fits — goodn/a
Merge Joinone row per side (if inputs sorted)always fits — goodthe sorts below it may spill

Key points

  • Selectivity → cardinality → cost, repeated at every node; errors at a leaf compound upward and can flip the join method above.
  • Equality selectivity comes from the MCV list or (1 − Σ mcv) / (n_distinct − n_mcv); range selectivity from the equal-height histogram.
  • Cost units: seq page 1.0, random page 4.0, cpu tuple 0.01 — ratios, not milliseconds. Every plan is a sum of those.
  • A sequential scan is cheaper than an index scan above a break-even that the arithmetic gives directly: ≈ 0.7% of a 1M-row, 64-byte-row table on cold cache, ≈ 3% warm, higher for wide or well-correlated rows.
  • Stale statistics produce a confident wrong plan; the signature is estimated rows ≠ actual rows in EXPLAIN ANALYZE, and the fix is ANALYZE.
  • Memory is part of the cost: hash joins and sorts that exceed work_mem spill to disk in batches, and raising work_mem can change the chosen plan.

Cost-based optimization

Cost-based optimization, one estimate at a time
A 1,000,000-row orders table, one predicate, two access paths. Step through how the planner turns statistics into a number — then let the statistics go stale.
1. Table stats

ANALYZE sampled the table and stored what it found in the catalog. Two numbers drive everything below: how many rows, and how many 8 KB pages they occupy. reltuples and relpages, in PostgreSQL's names.

relation:   orders
reltuples:  1,000,000
avg width:  400 B
relpages:   ceil(1,000,000 × 400 / 8192) = 48,829
indexes:    orders_merchant_id_idx (btree, height 3)
n_distinct
400
est. rows
2,495
actual rows
2,495
seq scan
58,829
index scan
6037.42
Seq Scan cost58,829
Index Scan cost6,037.425
The table and its statistics are modelled (no 1M-row table fits in a tab); the arithmetic uses this repo's cost constants and helpers, which mirror PostgreSQL's defaults.
1/8 · Table stats

Try it in the playground

When to use — and when not

Use it when
  • A cost model fits whenever data size or distribution can vary — it lets one query text get the right plan at 900 rows and at 900 million.
  • Extended statistics (CREATE STATISTICS) fit when two filtered columns are correlated and the independence assumption undercounts.
Avoid it when
  • Cost modelling fits badly when statistics cannot be kept fresh — tables rewritten every minute, temp tables — and a hint or a rewrite is more reliable than a plan built on numbers from an hour ago.
  • For a fixed workload on an embedded engine a rule-based planner is cheaper and predictable.

Failure modes

  • Autovacuum/ANALYZE behind on a fast-growing table: n_distinct and row counts an order of magnitude stale, plans flipped.
  • Correlated predicates estimated as independent: a 100× undercount of rows, a nested loop chosen over a million outer rows.
  • random_page_cost left at 4.0 on SSD/cached servers: index scans rejected that would have been faster.
  • A generic prepared-statement plan built for an average parameter, executed with a skewed one.
  • work_mem raised globally to fix one query: hundreds of concurrent sorts each take the new maximum and the server swaps.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.