Cost Estimation for Query Plans
A query optimizer often faces dozens of physically different ways to compute the same answer. Before it can pick a plan, it needs a number for each candidate. This number estimates how expensive that plan will be to run. Cost estimation is the machinery that turns a plan's shape into a predicted cost. That shape includes which access methods, join orders, and algorithms are used. The predicted cost is measured in disk I/Os, CPU cycles, or elapsed time. This lets plans be compared and ranked. This matters enormously in practice. A poorly estimated cardinality can mislead an optimizer's choices. It might prefer a nested-loop join that scans a billion-row table repeatedly. A hash join could have finished a thousand times faster instead. Every production relational database — PostgreSQL, Oracle, SQL Server, MySQL, Db2 — maintains statistics about tables and columns. These statistics specifically feed this estimation process. A large fraction of "the query is slow" bug reports trace back to bad cardinality estimates. The cause is rarely bad algorithms. The big picture is a pipeline. Catalog statistics (row counts, distinct values, histograms) feed selectivity formulas. Selectivity estimates feed cardinality estimates for intermediate results. Cardinality estimates then feed I/O and CPU cost formulas for each physical operator. The optimizer combines these bottom-up over a plan tree. It uses the estimated size of each intermediate result as input. That input feeds the next operator's cost formula. Errors compound multiplicatively as they propagate up the tree. This is why this topic deserves careful, precise treatment. This article builds the full pipeline from the ground up. It covers the physical I/O cost model and the catalog statistics databases actually store. It also covers the standard formulas for estimating selectivity of selections and joins. It addresses cardinality estimation for multi-predicate and multi-way queries. Finally, it shows how these combine into a total plan cost. It sets up directly for Join Algorithms: Nested-Loop and Query Optimization Fundamentals. There, these cost numbers are used to search a space of plans.
- Cost estimation predicts a plan's execution cost (usually dominated by disk I/O) before running it, so the optimizer can compare alternatives.
- Catalog statistics — table cardinality, distinct value counts, min/max, histograms — are the raw material for every estimate.
- Selectivity is the fraction of tuples a predicate is expected to satisfy; cardinality is selectivity times input size.
- Standard formulas assume uniformity and independence of predicates unless histograms or multi-column stats correct them.
- Join size estimates use the containment assumption: `|R ⋈ S| ≈ |R|·|S| / max(V(A,R), V(A,S))`.
- Costs propagate bottom-up through the plan tree; because estimates multiply, small per-operator errors compound into large plan-level errors.
Cost estimation converts statistics about data into predicted I/O and CPU costs, letting a query optimizer compare plans without running them.
The idea
A query execution plan is a tree of physical operators — table scans, index scans, joins, sorts — each with an implementation and an estimated cost, conventionally measured in disk block transfers (I/Os), sometimes augmented with CPU cost and communication cost in distributed settings. Because a single SQL query can be executed by exponentially many equivalent plans (different join orders, different access paths, different algorithms), the optimizer needs a cheap, purely analytical way to predict each plan's cost without executing it. That predictive machinery is cost estimation. Two quantities sit underneath every cost formula. Cardinality is the estimated number of tuples produced by an operator or sub-plan. Selectivity of a predicate P on a relation r is the estimated fraction of tuples in r satisfying P, so that the estimated cardinality of `σ_P(r)` is `selectivity(P) × |r|`. Selectivities compose: selection cost formulas convert them into output sizes, and join cardinality formulas extend them to two-relation and multi-relation predicates. All of this rests on catalog statistics — summary information about each relation and column that the database system maintains persistently and refreshes periodically (e.g., via `ANALYZE` or automatic statistics collection). The estimation problem is fundamentally about approximating an unknown joint distribution of data values using cheap-to-store summaries. Exact cardinality would require actually evaluating the query, which defeats the purpose; instead, systems use closed-form statistical models — uniform distribution within a domain, independence between predicates, containment of value sets across joining columns — that are wrong in detail but usually close enough in aggregate. Understanding exactly which assumptions are made, and where they break, is the core skill this article develops.
How it works
Step 1 — Catalog statistics
For each relation `r`, the system catalog typically stores:
- `n_r` — number of tuples in `r`.
- `b_r` — number of blocks containing tuples of `r`.
- `f_r` — blocking factor, tuples per block (`n_r ≈ b_r · f_r`).
- `V(A, r)` — the number of distinct values of attribute `A` in `r`.
- `min(A, r)`, `max(A, r)` — smallest/largest value of `A`.
- optionally, an equi-width or equi-depth histogram of `A`'s value distribution, and multi-column statistics for correlated attributes.
For each index, the catalog additionally stores the number of levels (height) of the index, the number of leaf blocks, and — for clustering indexes — enough information to estimate range-scan costs directly.
Step 2 — Selectivity of a single-table selection
Given predicate P on relation r, the standard formulas (assuming uniform distribution over `[min, max]`, and no histogram) are:
- Equality, `A = v`: selectivity ≈ `1 / V(A, r)`.
- Range, `A > v`: selectivity ≈ `(max(A,r) − v) / (max(A,r) − min(A,r))`, clipped to `[0, 1]`.
- Range, `A < v`: symmetric.
- Conjunction, `P1 ∧ P2`: selectivity ≈ `sel(P1) · sel(P2)` (independence assumption).
- Disjunction, `P1 ∨ P2`: selectivity ≈ `sel(P1) + sel(P2) − sel(P1)·sel(P2)` (inclusion–exclusion under independence).
- Negation, `¬P`: selectivity ≈ `1 − sel(P)`.
If a histogram is available for `A`, the estimator instead sums the fractions of buckets overlapped by the predicate's range, which is far more accurate when the distribution is skewed.
Step 3 — Join cardinality estimation
For a join `r ⋈ s` on attribute `A` (present in both relations), the classical formula uses the containment assumption: every value of `A` in the smaller-distinct-value relation appears in the larger one. Then: ``` |r ⋈ s| ≈ n_r · n_s / max(V(A, r), V(A, s)) ``` Reasoning: for each tuple in `r`, the expected number of matching tuples in `s` is `n_s / V(A, s)` (spreading `s`'s tuples uniformly over its distinct key values, assuming every value in `r` also occurs in `s`). Summing over all `n_r` tuples of `r` gives `n_r · n_s / V(A, s)`. Symmetric reasoning from `s`'s side gives `n_r · n_s / V(A, r)`; taking the max of the two `V` values in the denominator gives the more conservative (typically more accurate) estimate, since it corresponds to using the larger distinct-value count, which best approximates the "one matching group per join key" model when domains are not perfectly contained. For joins on multiple attributes or with additional local predicates, selectivities multiply under the independence assumption, exactly as with single-table conjunctions.
Step 4 — I/O cost of physical operators
Cost is normally reported in block transfers, `t_T` for average seek+transfer time. Representative formulas (`b_r` = blocks of `r`, `M` = memory buffer blocks available):
| Operator | Cost (block transfers) |
|---|---|
| Full table scan | `b_r` |
| Sorted-file scan on the sort key | `b_r` |
| Primary B⁺-tree index, equality on key | `≈ height of tree + 1` |
| Primary B⁺-tree index, range | `height + (blocks in range)` |
| Secondary index, equality (non-clustered) | `height + 1 + (matching tuples)` (one I/O per matching tuple, worst case) |
| Nested-loop join, no index | `b_r + n_r · b_s` |
| Block nested-loop join | `b_r + ⌈b_r / (M−2)⌉ · b_s` |
| External merge-sort | `2·b_r·(1 + ⌈log_{M−1}⌈b_r/M⌉⌉)` |
| Sort-merge join (sorted inputs) | `b_r + b_s` |
| Hash join (fits in memory buckets) | `3·(b_r + b_s)` |
These formulas are developed in depth for join algorithms elsewhere; the essential point here is that every one of them takes an estimated cardinality or block count as input, so the accuracy of the whole cost model is bounded by the accuracy of the underlying cardinality estimates.
Step 5 — Combining costs bottom-up over a plan tree
``` function EstimateCost(node): if node is a LeafScan(relation r, access-path): node.cardinality = EstimateCardinality(r) # from catalog stats node.cost = ScanCost(access-path, r) # table/index scan formula return node.cost else if node is a Selection(P, child): childCost = EstimateCost(child) node.cardinality = child.cardinality * Selectivity(P) node.cost = childCost # selections are usually "free" if pipelined return node.cost else if node is a Join(algorithm, left, right): leftCost = EstimateCost(left) rightCost = EstimateCost(right) node.cardinality = JoinCardinality(left, right) # containment-assumption formula node.cost = leftCost + rightCost + JoinAlgoCost(algorithm, left, right) return node.cost else if node is a Sort(child) or Aggregate(child): childCost = EstimateCost(child) node.cardinality = child.cardinality # (or group count for aggregate) node.cost = childCost + SortCost(child.cardinality, child.tupleSize) return node.cost ``` The algorithm is a straightforward post-order tree traversal: each node's cost depends only on its children's already-computed costs and cardinalities, so a single bottom-up pass suffices, and the estimated cardinality of every intermediate result is threaded upward as input to the next operator's formula. ```pseudocode // Full pipeline: statistics -> selectivity -> cardinality -> plan cost procedure OptimizeCostOfPlan(planTree): for each node in PostOrder(planTree): // children before parents stats = LookupCatalogStats(node) // n_r, b_r, V(A,r), histograms sel = EstimateSelectivity(node.predicates, stats) node.card = ComputeCardinality(node, sel) node.cost = ComputeIOCost(node, stats) return planTree.root.cost, planTree.root.card ```
Complexity & correctness
Time complexity of the estimation procedure itself. Given a fixed plan tree with `k` operators, computing all cardinalities and costs is a single post-order traversal, so it runs in `O(k)` time, assuming catalog lookups (`V(A,r)`, histogram bucket lookup) are `O(1)` or `O(log(#buckets))` via binary search. This is essentially free compared to executing the query — which is exactly the point: estimation must be cheap enough to run for every candidate plan the optimizer considers. The harder complexity issue is in the search that surrounds estimation (covered in Query Optimization Fundamentals): the number of join orders for `n` relations is `Ω(n!)` for left-deep trees and worse for bushy trees, so cost estimation must be called on the order of `O(2^n)` or more sub-plans if the optimizer enumerates all subsets (as in dynamic-programming join-order enumeration). Because a single estimation call is `O(k)`, the total optimization time is bounded by (number of sub-plans considered) × `O(k)`, which is why practical optimizers prune the search space (dynamic programming over subsets, or greedy/heuristic join ordering) rather than enumerating exhaustively.
Space. Catalog statistics use `O(1)` space per column for basic summaries (`n_r, V(A,r), min, max`) and `O(h)` space for a histogram with `h` buckets, independent of `n_r`. This is the essential tradeoff: statistics are a lossy compression of the true data distribution, sized so that maintaining and querying them costs far less than storing or scanning the actual table.
Correctness / accuracy argument. "Correctness" for a cost estimator is not a Boolean property but a statistical guarantee under stated assumptions: 1. Selection selectivity is exact if the underlying distribution really is uniform over `[min, max]`, and each value's frequency is close to `n_r / V(A,r)`. With an equi-depth histogram of `h` buckets, the error introduced by within-bucket uniformity is bounded by the maximum intra-bucket skew, which shrinks as `h` grows (at the cost of `O(h)` storage per column). 2. Join cardinality is exact only under the containment assumption — that the set of distinct values of the smaller-`V` relation is a subset of the other relation's values — and under the assumption that values are uniformly spread across matching groups. When both hold, the derivation in Step 3 is an exact expectation calculation, not a heuristic; the error comes entirely from violations of these assumptions (e.g., a foreign key that is genuinely a subset of the primary key satisfies containment exactly, which is why the formula is especially reliable for primary-key/foreign-key joins). 3. Independence of predicates is the weakest link: if two predicates are correlated (e.g., `city = 'NYC' AND zip = '10001'`), multiplying their selectivities drastically underestimates the true selectivity (the true selectivity of the conjunction can be as large as `min(sel(P1), sel(P2))`, not their product). This is the single largest source of real-world cardinality misestimation, and it motivates multi-column statistics and query-driven correlation statistics in modern systems. The honest tradeoff: exact cardinality requires executing the query; the formulas above trade exactness for tractability, and their error grows precisely in proportion to how much real data violates uniformity, containment, and independence.
Worked example
Consider `Employees(emp_id, dept_id, salary)` and `Departments(dept_id, name)`, with the query: ```sql SELECT e.emp_id, d.name FROM Employees e, Departments d WHERE e.dept_id = d.dept_id AND d.name = 'Sales'; ``` Catalog statistics:
| Relation | n | b | V(dept_id) | V(name) |
|---|---|---|---|---|
| Employees | 100,000 | 2,000 | 50 | — |
| Departments | 50 | 2 | 50 | 50 |
Step 1 — selectivity of `d.name = 'Sales'`: ``` sel = 1 / V(name, Departments) = 1/50 = 0.02 ```
Step 2 — cardinality after selection on Departments: ``` |σ_{name='Sales'}(Departments)| = 0.02 × 50 = 1 ``` (This makes sense: department names are presumably unique, so exactly one row matches.)
Step 3 — join cardinality, `Employees ⋈ σ(Departments)` on `dept_id`: Applying the join formula with `r = Employees`, `s = σ(Departments)` (now `n_s = 1`, but `V(dept_id, s)` is still bounded by 1 since only one department value survives): ``` |r ⋈ s| ≈ n_r · n_s / max(V(dept_id, r), V(dept_id, s)) = 100000 · 1 / max(50, 1) = 100000 / 50 = 2000 ``` This says: about 2,000 employees are expected to belong to the Sales department — exactly `n_r / V(dept_id, r)`, which is the correct expected group size if departments are roughly evenly sized. Note that plugging in the post-selection `V(dept_id, s) = 1` into the max would be wrong if used naively (it would suggest every employee matches); real optimizers track the selectivity-adjusted distinct-value count for `s`, effectively `V(dept_id, s) ≈ V(dept_id, Departments) × sel(name='Sales')`, rounded to at least 1, giving the same `max(50, 1) = 50` behavior. This subtlety — updating `V(A,·)` estimates for filtered sub-plans, not just raw base tables — is exactly the kind of detail that separates a naive optimizer from a careful one.
Step 4 — costs of two candidate plans: Plan A — Nested-loop join, Departments (filtered) as outer: ``` cost = b_Departments + |σ(Departments)| · b_Employees = 2 + 1 · 2000 = 2002 block transfers (plus the 2-block scan+filter of Departments) ``` Plan B — Nested-loop join, Employees as outer (no index, no filter pushed): ``` cost = b_Employees + n_Employees · b_Departments = 2000 + 100000 · 2 = 202,000 block transfers ``` The optimizer, using nothing more than the catalog statistics and the formulas above, correctly predicts that filtering `Departments` first and using it as the outer relation of a nested-loop join (Plan A) is roughly 100× cheaper than the naive ordering (Plan B) — precisely the kind of decision cost-based Join Algorithms: Nested-Loop optimization is built to make, and precisely why cardinality estimates must be computed for every intermediate result, not just base tables.
Common pitfalls & traps
| Pitfall | Why it happens | Consequence |
|---|---|---|
| Assuming independence of correlated predicates | Standard formulas multiply selectivities by default | Severe underestimation of result size, leading to bad join order/algorithm choice |
| Stale statistics | `ANALYZE`/auto-stats not run after bulk load or heavy updates | Estimates reflect old data distribution; costs wildly off |
| Using base-table `V(A,r)` after a filter | Naively reusing catalog `V(A,r)` for a filtered sub-plan | Join cardinality either over- or under-estimated for subsequent joins |
| Ignoring skew (no histogram) | Uniform-distribution assumption used for a highly skewed column (e.g., `status='active'` matching 90% of rows) | Selection selectivity estimate is off by an order of magnitude |
| Underestimating multi-way join cardinality | Errors from each pairwise join multiply as more joins are added | Errors compound exponentially with query complexity, producing catastrophically bad plans for large join graphs |
| Treating estimated cost as a guarantee | Forgetting cost is a statistical estimate, not measured runtime | Overconfidence in a single "optimal" plan; no fallback for adaptive re-optimization |
Pioneers & history
The formulas and system architecture described here trace back to the earliest cost-based relational optimizers, which first demonstrated that a small set of catalog statistics could drive automatic, correctness-preserving plan selection at scale.
Origins · 1 minPatricia Selinger and how this was discovered
Patricia Selinger led the development of the System R query optimizer at IBM Research, publishing the 1979 paper "Access Path Selection in a Relational Database Management System," which introduced cost-based join-order optimization using dynamic programming over catalog statistics — the direct ancestor of every cost model described in this article.
Where this matters
Every general-purpose relational database — PostgreSQL, MySQL/InnoDB, Oracle, Microsoft SQL Server, IBM Db2 — runs a cost-based optimizer whose join-order and access-path decisions are driven directly by the selectivity and cardinality formulas described here. Data warehouse and analytics engines (Snowflake, BigQuery, Spark SQL, Presto/Trino) apply the same principles at larger scale, often adding sampling-based cardinality estimation and adaptive query execution to correct for estimation errors mid-query when actual row counts diverge sharply from predictions. Statistics collection commands (`ANALYZE` in PostgreSQL, `UPDATE STATISTICS` in SQL Server, automatic optimizer statistics gathering in Oracle) exist specifically to keep these catalog inputs fresh. Understanding this pipeline is the prerequisite for reasoning about Join Algorithms: Nested-Loop and other physical join implementations, and for the broader plan-search machinery covered under Query Optimization Fundamentals and The Query Optimizer, where these cost numbers are compared across thousands of candidate plans to choose the one actually executed.
Ask the tutor: How do dynamic-programming join-order enumeration algorithms use these cost estimates to search the plan space efficiently?
Why don't databases just compute exact cardinalities instead of estimating them?
Computing an exact cardinality for an intermediate result generally requires actually executing the corresponding sub-plan — scanning, filtering, and joining the real data — which is exactly the cost the optimizer is trying to avoid paying for every candidate plan. If the optimizer had to execute each candidate to know its cost, there would be no benefit to comparing plans beforehand at all. Instead, the system maintains cheap, precomputed summary statistics (row counts, distinct-value counts, histograms) and uses closed-form formulas to approximate cardinalities in `O(1)` or `O(log h)` time per operator. This tradeoff sacrifices exactness for speed, and is only viable because the formulas are accurate enough, on typical data, to make good relative comparisons between plans rather than perfectly precise absolute cost predictions.
What is a histogram and why does it improve selectivity estimation?
A histogram partitions a column's value range into buckets and records, per bucket, either an equal range width (equi-width) or an equal tuple count (equi-depth). Selectivity for a range predicate is then estimated by summing the fractions of buckets overlapped, rather than assuming the entire column is uniformly distributed between its global min and max. This matters because real columns are rarely uniform — a `status` column might have 90% of rows equal to `'active'` — and a single global uniformity assumption would badly misestimate such skew. Histograms cost `O(h)` space per column (h buckets, often 100–256 in production systems) and dramatically reduce estimation error for skewed distributions, at negligible additional lookup cost.
What is the containment assumption in join cardinality estimation, and when does it fail?
The containment assumption states that the set of distinct join-key values in the relation with fewer distinct values is a subset of the distinct join-key values in the other relation. It underlies the formula `n_r·n_s / max(V(A,r), V(A,s))`, since it lets the estimator assume every tuple in the smaller-domain relation has at least one match. It holds essentially exactly for primary-key/foreign-key joins, since every foreign key value must reference an existing primary key. It fails for joins on non-key, independently populated columns — e.g., joining two tables on a `country_code` column populated from different, only partially overlapping source systems — where actual cardinality can be much smaller than predicted.
How does the independence assumption for conjunctive predicates cause real production incidents?
When two predicates are correlated — for example, `WHERE city = 'San Francisco' AND state = 'CA'` — the true selectivity of the conjunction is close to `sel(city='San Francisco')` alone, since nearly all San Francisco rows also have `state='CA'`. Multiplying the two selectivities as if independent yields a much smaller number, e.g., `sel(city)·sel(state)`, which can underestimate the true row count by 10–100×. This cascades: the optimizer may then choose a nested-loop join expecting a handful of rows, only to have the inner loop execute thousands or millions of times against the actual, much larger intermediate result, turning a fast plan choice into a catastrophically slow one. Multi-column statistics and extended statistics features exist specifically to detect and correct such correlations.
How do modern systems compensate for cardinality estimation errors beyond static formulas?
Beyond richer statistics (histograms, multi-column/extended statistics, sampling), several systems add runtime feedback: adaptive query execution monitors actual intermediate cardinalities during execution and can switch join algorithms or re-optimize remaining stages if actual counts diverge sharply from estimates. Some systems periodically sample the base tables at query time to refine estimates for particularly selective or unusual predicates. Others log actual-versus-estimated cardinalities from past executions and feed them back into future statistics ("cardinality feedback" or query-store-driven tuning). These techniques do not replace the formulas covered here; they supplement them by catching the cases where uniformity, independence, or containment assumptions were violated badly enough to produce a poor plan.
Is minimizing estimated I/O cost the same as minimizing actual query latency?
Not exactly, though it is a reasonable proxy in most systems. Classical cost models emphasize block transfers because disk I/O historically dominated query latency, but modern systems must also account for CPU cost (predicate evaluation, hashing, sorting comparisons), memory bandwidth, buffer cache hit rates, and — in distributed/cloud systems — network transfer between nodes. Many optimizers use a weighted combination of I/O and CPU cost, calibrated to the specific hardware, rather than a pure block-transfer count. The estimation techniques in this article (selectivity, cardinality) remain the foundation regardless of which cost units are ultimately used, since CPU and network costs are themselves usually proportional to estimated cardinalities.
Why is join-order selection so sensitive to cardinality estimation errors specifically, more than selection cost?
A single-table selection's cost formula depends on one estimated selectivity, so an error there is bounded and localized. A join order, in contrast, is a sequence of operators where each join's estimated cardinality is used as the input size to the next join's cost and cardinality formulas. An underestimate at the first join doesn't just mislead that one decision — it propagates forward, making every subsequent join in the chosen order look artificially cheap. With `n` joins, an optimizer effectively multiplies together `n` layers of potentially erroneous estimates, which is why query optimization research pays disproportionate attention to join cardinality accuracy compared to single-predicate selectivity accuracy.