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.
Why this exists
The mechanism as the answer to a problem — read this before the name.
- 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.
↓ - Naive solution
Count the rows each plan would touch by scanning the table at plan time.
↓ - 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.
↓ - 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.
↓ - 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.↓ - 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.
↓ - Real database
PostgreSQL:
ANALYZEfillspg_statistic(viewpg_stats), the planner readsreltuples/relpagesand per-columnn_distinct,most_common_vals,histogram_bounds; constantsseq_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.
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
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.
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 rowsI/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.
1export const SEQ_PAGE_COST = 1.02export const RANDOM_PAGE_COST = 4.03export const CPU_TUPLE_COST = 0.014export const CPU_INDEX_TUPLE_COST = 0.0055export const PAGE_BYTES = 81926 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_COST11}12 13export function indexScanCost(matched: number, totalRows: number): number {14 const descent = btreeHeight(totalRows) * RANDOM_PAGE_COST15 const scanIndex = matched * CPU_INDEX_TUPLE_COST16 const heap = matched * (RANDOM_PAGE_COST * 0.6 + CPU_TUPLE_COST) // rows sharing a page are discounted17 return descent + scanIndex + heap18}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.
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
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 columns — WHERE 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.
| Operator | Memory needed | Fits in work_mem | Exceeds work_mem |
|---|---|---|---|
| Hash Join (build) | build side rows × width | one pass each side — good | batches to disk: write + read both sides — spill |
| HashAggregate | one entry per group | one pass — good | batches by group hash — spill |
| Sort | all input rows | quicksort in memory — good | external merge sort, log(batches) passes — spill |
| Nested Loop | one outer row | always fits — good | n/a |
| Merge Join | one row per side (if inputs sorted) | always fits — good | the 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
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)
Try it in the playground
EXPLAIN ANALYZE SELECT count(*) FROM orders WHERE status = 'paid';
CREATE INDEX orders_status_idx ON orders (status); EXPLAIN ANALYZE SELECT count(*) FROM orders WHERE status = 'paid';
EXPLAIN ANALYZE SELECT u.country, count(*) FROM orders o JOIN users u ON u.id = o.user_id GROUP BY u.country;
When to use — and when not
- 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.
- 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.