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.

Key takeaways
  • 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.
The core insight

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):

OperatorCost (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 ```

Figure
Bottom-up cost/cardinality propagation over a join plan tree
Scan Employees n=100000, b=2000 Scan Departments n=50, b=2 Scan Salaries n=100000, b=1800 σ dept='Sales' sel=1/50 → card=2000 ⋈ dept_id card≈2000·50/50=2000 ⋈ emp_id (hash join) card≈2000·100000/100000=2000 Total plan cost = Σ scan costs + Σ join costs computed bottom-up

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:

RelationnbV(dept_id)V(name)
Employees100,0002,00050—
Departments5025050

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

PitfallWhy it happensConsequence
Assuming independence of correlated predicatesStandard formulas multiply selectivities by defaultSevere underestimation of result size, leading to bad join order/algorithm choice
Stale statistics`ANALYZE`/auto-stats not run after bulk load or heavy updatesEstimates reflect old data distribution; costs wildly off
Using base-table `V(A,r)` after a filterNaively reusing catalog `V(A,r)` for a filtered sub-planJoin 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 cardinalityErrors from each pairwise join multiply as more joins are addedErrors compound exponentially with query complexity, producing catastrophically bad plans for large join graphs
Treating estimated cost as a guaranteeForgetting cost is a statistical estimate, not measured runtimeOverconfidence 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
PS
Patricia Selinger
1942–present · IBM System R project, 1970s

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.

History
1970
— Edgar F. Codd publishes the relational model, separating logical queries from physical execution.
1974
— IBM's System R project begins, requiring an automatic way to choose among physical plans.
1979
— Selinger et al. publish the System R optimizer paper, introducing catalog-driven cost-based optimization and dynamic-programming join-order search.
1988
— Y. Ioannidis and S. Christodoulakis formally analyze error propagation in cardinality estimation, showing errors compound multiplicatively across joins.
1990
s–2000s — Commercial systems (Oracle, DB2, SQL Server, PostgreSQL) add histograms, multi-column statistics, and adaptive/feedback-based re-estimation to counter independence-assumption failures.

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.

Before this

Where this leads