The Query Optimizer

A database query is a promise, not a program. The user states what rows they want, not how to fetch them. Turning a declarative `SELECT` with eight joined tables into an efficient sequence of scans, joins, and sorts is hard. It is one of the hardest combinatorial problems solved routinely inside production software. There are more ways to order and shape a join than there are atoms worth counting, for anything but the smallest queries. So a database system cannot simply try every plan. The query optimizer is the component that searches this astronomically large space of equivalent execution plans. It picks one that is cheap, without ever exhaustively trying most of them. It sits between the parser (which builds a logical representation of the query) and the execution engine (which runs a physical plan of operators). Its decisions — which join order, which join algorithm, which access path — routinely mean the difference between a query that returns in milliseconds and one that never finishes. This article focuses on the combinatorial heart of optimization: plan enumeration and join-order search using dynamic programming. This is the classical System R style algorithm still at the core of PostgreSQL, Oracle, DB2, and SQL Server today. It also covers the heuristics and randomized alternatives used when the exact search becomes too expensive. It assumes you already know how individual operators (sort-merge join, hash join) work. It also assumes you know how their costs are estimated. Here we assemble those pieces into a search procedure. By the end you will be able to trace, by hand, how a cost-based optimizer builds up an optimal join plan. This spans from single tables to the full query. You will understand exactly why dynamic programming is both correct and efficient for this problem. You will also know where the technique breaks down and what real systems do instead.

Key takeaways
  • The optimizer searches a space of logically equivalent plans and picks the one with lowest estimated cost.
  • Join order matters enormously: for `n` relations there are exponentially many orders, so exhaustive search is infeasible beyond small `n`.
  • Dynamic programming (System R style) enumerates plans bottom-up by subset size, reusing optimal sub-plans via a principle of optimality.
  • Interesting orders must be tracked per subset, because a locally suboptimal sub-plan can still be globally optimal if it avoids a later sort.
  • DP is exact but exponential in the number of relations (`O(3ⁿ)`-ish); beyond roughly 10–15 tables, systems switch to heuristics, greedy joins, or randomized search.
  • The optimizer's decisions rest entirely on cost estimates, so its correctness (finding a valid plan) is separate from its optimality (finding the cheapest plan under possibly wrong statistics).
The core insight

Because relational algebra guarantees many equivalent rewritings of the same query, the optimizer's job reduces to a shortest-path search over a plan space, and dynamic programming solves that search exactly by building optimal plans for small joins into optimal plans for larger ones.

The idea

A SQL query is first translated into a logical query plan: a tree of relational algebra operators (select, project, join) with no commitment to algorithms or order. Because joins are associative and commutative, and selections and projections can be pushed around, this logical tree has many equivalent rearrangements. A physical plan additionally fixes, for every logical operator, a concrete algorithm (hash join vs. sort-merge join vs. nested loops) and an access path (sequential scan, index scan). The set of all physical plans equivalent to the query is the search space; the optimizer's job is plan enumeration: generate a manageable subset of this space and pick the least-cost member, using a cost model built from statistics, selectivity estimates, and per-operator cost formulas (covered in Cost Estimation for Query Plans). The dominant source of combinatorial explosion is join order. Given relations `R1, R2, …, Rn` to be joined, there are, for a strictly left-deep tree, `n!` orderings, and if bushy (non-left-deep) trees are allowed there are even more shapes — roughly `(2n-2)!/(n-1)!` distinct binary tree shapes with labeled leaves, exponential in `n` either way. Trying all of them and costing each from scratch is wasteful, because different join orders share overlapping sub-joins whose optimal plans can be computed once and reused. This is exactly the structural signature of a dynamic programming (DP) problem: overlapping subproblems plus optimal substructure. The key subtlety that separates this from a naive "cheapest subplan wins" DP is interesting orders. A sub-join's output might be needed sorted (for a later merge join or for `ORDER BY`/`GROUP BY`), so the optimizer must keep, for every subset of relations, not just the single cheapest plan but the cheapest plan for each output order that some later operator might exploit — because a slightly more expensive sub-plan that happens to already be sorted the right way can save an expensive sort downstream and win globally. This idea originates in the classic System R optimizer and remains the conceptual backbone of essentially every cost-based optimizer in use today.

How it works

The standard algorithm is bottom-up dynamic programming over subsets of relations, augmented with interesting-order bookkeeping. We build it up in three stages: the plain subset-DP recurrence, then adding physical properties, then the full algorithm with pruning.

Stage 1 — the subset recurrence. For a query joining relations `{R1,…,Rn}`, define `bestPlan(S)` for every subset `S ⊆ {R1,…,Rn}` as the cheapest plan computing the join of exactly the relations in `S`, ignoring for a moment which order the result comes out in. The base case is a single relation: `bestPlan({Ri})` is the cheapest access path for `Ri` alone (sequential scan vs. available index scans, each costed using selectivity estimates from Cost Estimation for Query Plans). For `|S| ≥ 2`: ``` bestPlan(S) = min over all splits S = S1 ∪ S2 (S1, S2 disjoint, both nonempty, and (S1, S2) connected by at least one applicable join predicate) of: joinCost(bestPlan(S1), bestPlan(S2)) ``` The join itself may use sort-merge, hash join, or nested loops (from Join Algorithms: Sort-Merge and Join Algorithms: Hash Join), and the optimizer costs each applicable algorithm for the split and keeps the cheapest. Requiring `S1` and `S2` to be connected by a join predicate — rather than allowing arbitrary cross products — is what keeps the search space in practice much smaller than the full combinatorial bound, since most queries have sparse join graphs.

Stage 2 — interesting orders. Instead of one `bestPlan(S)`, the optimizer maintains a small table `bestPlan(S, order)` for each subset `S` and each "interesting" sort order — an order that some later part of the query (a `GROUP BY`, an `ORDER BY`, a merge join with a relation outside `S`) could exploit. For each `(S, order)` pair we keep only the cheapest plan whose output happens to satisfy `order` (or which can cheaply be made to, by using a sort-merge join that produces it as a byproduct), plus always the single overall cheapest plan for `S` regardless of order (the "unordered" entry). This is a form of Pareto-optimal bookkeeping: a plan survives if no other plan for the same subset is both cheaper and at least as useful order-wise.

Stage 3 — full System R style algorithm. ``` PSEUDOCODE: System-R style DP join enumeration Input: relations R = {R1, ..., Rn} join graph G (predicates connecting relations) set of interesting orders O = {o1, ..., ok} (derived from ORDER BY, GROUP BY, and merge-join opportunities) Output: optimal physical plan for the full join of R 1. for each Ri in R: 2. for each access path AP of Ri (seq scan, each usable index): 3. plan <- cost and build a scan-plan using AP 4. record plan as a candidate for bestPlans[{Ri}] 5. prune bestPlans[{Ri}] to Pareto-optimal set over (cost, order) 6. for size s = 2 to n: 7. for each subset S of R with |S| = s: 8. bestPlans[S] <- empty set 9. for each way to split S = S1 (union) S2, S1,S2 nonempty, disjoint: 10. if S1 and S2 are NOT connected by a join predicate in G: 11. skip this split // avoid cartesian products 12. for each plan1 in bestPlans[S1]: 13. for each plan2 in bestPlans[S2]: 14. for each join algorithm A in {hash join, sort-merge, nested loop} applicable to (plan1, plan2): 15. newPlan <- build join(plan1, plan2, A) 16. cost <- estimateCost(newPlan) // uses stats from // Cost Estimation module 17. order <- outputOrder(newPlan) // e.g. sort-merge // preserves/produces an order 18. add newPlan to bestPlans[S] tagged with (cost, order) 19. bestPlans[S] <- prune bestPlans[S] to Pareto-optimal set: keep a plan iff no other plan in the set has both lower-or-equal cost AND an order at least as useful for every remaining need 20. return the plan in bestPlans[R] with globally minimum (cost + cost of any final required sort not already satisfied) ``` Two implementation notes make this efficient in practice. First, `bestPlans[S]` is stored in a hash table keyed by the bitmask of `S`, so subset lookups are `O(1)` amortized; there are `2ⁿ` such keys. Second, real optimizers additionally apply branch-and-bound pruning: once a candidate's partial cost already exceeds the best complete plan found so far, that branch is abandoned early, which does not change worst-case complexity but dramatically shrinks the constant factor in practice.

Figure
Bottom-up DP lattice over subsets of {A, B, C, D}, with interesting-order tagging
size 1 A B C D size 2 AB AC* AD BC BD CD size 3 ABC ABD* ACD BCD size 4 ABCD (final) * = kept extra because its output order (sort-merge on join key) is "interesting" for a later merge join, even if not cheapest overall.

Complexity & correctness

Correctness (why DP finds the true optimum). The argument rests on a principle of optimality: the cheapest plan for joining a subset `S` is built exclusively from cheapest plans (per relevant order) of some partition of `S` into two smaller connected subsets, combined with one join operator. This holds because join cost functions used in practice (for hash join, sort-merge, nested loop) depend only on: the sizes and orders of the two inputs, and the join algorithm chosen — not on the internal structure of how each input's plan was built. Formally, cost is separable: `cost(join(P1, P2, A)) = f(cost(P1), cost(P2), size(P1), size(P2), A)` for a fixed, known function `f`. If a strictly cheaper plan for `S1` existed but were discarded, substituting it into any optimal plan for `S` that used `S1` as a sub-join would only decrease (or keep equal, accounting for order) the total cost — a contradiction. This is the same substitution argument that underlies optimality proofs for shortest paths (Bellman's principle) and for other subset-DP problems like the traveling-salesman DP. The one place this substitution argument needs care is exactly interesting orders: a plan for `S1` that is cheaper in isolation might force an extra sort later, making the global total worse. This is why the algorithm does not discard a slightly more expensive sub-plan if it produces a useful order "for free" — the Pareto pruning in step 19 is precisely the fix that restores the optimality argument: for every subset, we keep every plan that is undominated in the (cost, order) space, so that whichever order the eventual parent operator needs, the right candidate survives.

Time complexity. Let `n` be the number of relations. There are `2ⁿ` subsets. For each subset `S` of size `s`, there are `2^(s-1) - 1` ways to split it into two nonempty disjoint parts (ignoring which part is "left"), so summing over all subsets gives ``` Σ (over all S ⊆ R) (number of splits of S) = O(3ⁿ) ``` This `3ⁿ` bound comes from a standard identity: summing over all subsets `S`, the number of ordered pairs `(S1, S2)` with `S1 ∪ S2 = S` and `S1 ∩ S2 = ∅` equals `3ⁿ` (each of the `n` elements independently goes to `S1`, `S2`, or neither, i.e. outside `S` entirely). Each split does `O(1)` work per join algorithm considered (a constant number of algorithms), plus a constant factor for the number of interesting orders `k` tracked per subset (typically small, since real queries have few `ORDER BY`/`GROUP BY`/merge-join needs). So the algorithm runs in `O(3ⁿ · k)` time and `O(2ⁿ · k)` space to store `bestPlans`.

Honest tradeoffs. `O(3ⁿ)` is exponential, so this exact DP is only practical for modest `n` (roughly up to 12–16 tables depending on implementation and pruning aggressiveness). Restricting to left-deep trees only — where every join has at least one base relation as its right input, common because it matches pipeline-friendly execution — cuts the shape space but the join-order search over which relation joins next is still `O(n · 2�ff)`-ish (`O(n²·2ⁿ)` with a simpler recurrence over subsets and "last relation added"), still exponential but with a smaller base and much smaller constants than full bushy enumeration. Beyond the exact-DP regime, production systems fall back to:

  • Greedy / heuristic join ordering (e.g., always join the two currently-smallest relations, or order by decreasing selectivity), `O(n log n)` or `O(n²)`, no optimality guarantee.
  • Randomized search (simulated annealing, iterative improvement, genetic algorithms — PostgreSQL's `GEQO` kicks in above a configurable table count), trades a probabilistic near-optimum for polynomial-ish runtime.
  • Bounded DP (limit bushy-tree consideration, or DP only over left-deep shapes, or cap subset size explored exhaustively then switch heuristic).

None of these change the fundamental point: DP is exact but exponential; heuristics are polynomial but inexact. The choice is a deliberate engineering tradeoff, not an oversight.

Worked example

Consider the query joining four relations with the following (illustrative) statistics and join predicates, forming a chain join graph `A – B – C – D` (A joins B, B joins C, C joins D; no direct A–C, A–D, or B–D predicates):

RelationRowsNotes
A (Orders)10,000index on `custId`
B (Customers)2,000join key `custId`
C (Regions)50join key `regionId`
D (Shipments)40,000join key `orderId`, also needs result sorted by `orderId` for a later merge with an external stream (an "interesting order")

Step 1 — base relations. Each `bestPlans[{X}]` holds the cheapest scan, plus (for D) a second candidate: a plan that produces rows pre-sorted on `orderId` via an index scan, kept because it satisfies the interesting order even though a sequential scan might have lower raw scan cost.

Step 2 — size-2 subsets. Only connected pairs are considered: `AB`, `BC`, `CD` (not `AC`, `AD`, `BD`, since there's no direct predicate — a cross product would be far more expensive and is skipped by the connectivity check in line 10 of the pseudocode). For `AB`: hash join with B (2,000 rows) as the build side costs far less than the reverse, since hash join cost is dominated by building the hash table on the smaller input. For `CD`: since D must eventually be sorted by `orderId` anyway, a sort-merge join here is evaluated too, and if it produces `orderId`-sorted output "for free" (using an already-sorted access path on D), it is kept as the interesting-order-tagged candidate for `CD` even if a hash join is nominally a few cost-units cheaper.

Step 3 — size-3 subsets. `ABC` can only be built from `AB ⋈ C` (connected: B–C predicate exists) or `A ⋈ BC` (connected via B–C join's presence of B). Both are tried and costed; suppose `AB ⋈ C` wins because `AB` is small (after `custId` join, ~2,000 rows) and hash-joining against tiny `C` (50 rows) is cheap. `BCD` is similarly built from `BC ⋈ D` or `B ⋈ CD`; here the `CD`-with-order-tag candidate is combined with `B` if doing so still yields useful order, or the plain cheapest `CD` is used otherwise — the algorithm compares both and keeps whichever survives Pareto pruning.

Step 4 — size-4, the full join. `ABCD` can only be assembled from connected splits of the chain: `AB ⋈ CD`, `ABC ⋈ D`, or `A ⋈ BCD` (splits like `AC ⋈ BD` are impossible since `AC` and `BD` were never built — no predicate connects them). Suppose the costs come out as:

SplitEstimated cost (I/O + CPU units)Produces `orderId` order?
`AB ⋈ CD` (hash join)18,500no
`AB ⋈ CD` (using CD's order-tagged plan, sort-merge)19,200yes
`ABC ⋈ D` (hash join)21,000no

If nothing downstream needs `orderId` order, the optimizer picks `AB ⋈ CD` via hash join at cost 18,500 — the naive cheapest. But if the query's outer context needs the final result sorted by `orderId` (say for a merge with an external result stream, our stated requirement on D), the plain hash-join plan at 18,500 would still need an explicit final sort, say costing 2,000 more, totaling 20,500 — worse than the 19,200 order-preserving alternative. Because the optimizer tracked the order-tagged `CD` candidate all along instead of discarding it as "locally more expensive," it correctly selects the 19,200 plan as globally optimal. This is precisely the scenario the interesting-order bookkeeping in Stage 2 exists to catch.

Common pitfalls & traps

PitfallWhy it happensConsequence
Discarding all but the single cheapest plan per subsetForgetting interesting orders; treating DP as plain shortest-path over cost aloneMisses globally optimal plans that need a later sort avoided; can be much more expensive in practice, not just marginally
Allowing unrestricted cross products in the split searchNot checking join-graph connectivity before considering a splitSearch space blows up needlessly; cost estimates for cross products are usually catastrophic, wasting enumeration effort
Using stale or coarse cardinality statisticsStatistics not refreshed after bulk loads/deletes, or histograms too coarse for skewed dataOptimizer picks a "cheapest" plan that is actually far from optimal — the algorithm is exact, but garbage-in-garbage-out on costs
Treating left-deep-only enumeration as always sufficientSimplifying implementation, assuming pipelining always winsMisses genuinely cheaper bushy plans, especially when two small dimension tables should be joined first independent of fact-table order
Ignoring correlated predicates across relationsAssuming independence when estimating selectivity of multi-column joins/filtersSelectivity estimates multiply errors, sometimes by orders of magnitude, silently corrupting every cost comparison upstream
Off-by-one in subset enumeration (e.g., allowing `S1` or `S2` empty)Careless loop bounds when generating splitsEither infinite/degenerate "joins" with nothing, or double-counting the same plan, wasting time or producing wrong costs

Pioneers & history

The dynamic-programming approach to join-order optimization was introduced as part of the System R project at IBM Research, whose cost-based optimizer became the template nearly every relational database has followed since. The paper by Selinger, Astrahan, Chamberlin, Lorie, and Price, "Access Path Selection in a Relational Database Management System" (1979), is the canonical reference: it introduced both the subset-DP recurrence and the concept of interesting orders in essentially the form still used today.

Origins · 1 minPatricia G. Selinger and how this was discovered
PG
Patricia G. Selinger
1942–present · IBM Research, System R project, 1970s

Selinger led the design of System R's query optimizer, the first fully cost-based relational optimizer, and co-authored the 1979 paper that formalized dynamic-programming join enumeration and interesting-order tracking — techniques whose structure remains recognizable in every major commercial and open-source database engine today.

History
1970
— Codd publishes the relational model, making declarative query languages (and hence the need for an optimizer) possible.
1974
— IBM begins the System R project to build a working relational database with SQL.
1979
— Selinger et al. publish the System R optimizer paper, introducing cost-based DP join enumeration and interesting orders.
1986
— commercial systems (DB2, and soon Oracle, Informix, Sybase) adopt cost-based optimization following the System R template.
1990
s–2000s — randomized and heuristic search (simulated annealing, genetic algorithms, greedy join ordering) added to handle queries with many-way joins beyond DP's practical reach.

Where this matters

Every major relational database — PostgreSQL, MySQL, Oracle, SQL Server, DB2, and cloud warehouses like Snowflake, BigQuery, and Redshift — runs some variant of cost-based DP join enumeration, typically with a cutover to heuristic or randomized search above a configurable table count (PostgreSQL's `join_collapse_limit` and `GEQO` threshold are direct, user-visible knobs controlling exactly this switch). Analytics engines processing star-schema and snowflake-schema queries with many small dimension tables joined to one large fact table depend heavily on correct join ordering to avoid materializing enormous intermediate results. Distributed query engines (Spark SQL, Presto/Trino, distributed variants of Postgres) extend the same DP core with additional physical properties beyond sort order — data partitioning and replication — since a "distribution-interesting" property (like being partitioned on the join key) can save an expensive network shuffle exactly the way a sort order saves a `Sort` operator, a direct generalization of the interesting-orders idea. Understanding this algorithm is also foundational background for Query Optimization Fundamentals and connects directly to how execution engines later exploit the chosen plan via Join Algorithms: Sort-Merge and Join Algorithms: Hash Join.

Ask the tutor: How would this DP algorithm need to change to account for distributed join costs like network shuffles across partitioned data?

Why not just always use left-deep trees to simplify the search?

Left-deep trees are attractive because they let the execution engine pipeline results — the outer relation of each join can start feeding rows to the next join immediately, without waiting for it to fully materialize, which suits hash join and nested-loop join well. This restriction shrinks the enumeration from all bushy shapes to `n!` linear orders, still exponential but with a much smaller constant. The cost is that left-deep restriction can miss genuinely cheaper bushy plans, for instance when two small dimension tables should be joined together first, independent of where they sit in a chain, before either touches the large fact table. Many optimizers therefore allow bushy trees but bias cost estimates and pruning to favor pipeline-friendly shapes when costs are close, balancing both concerns rather than picking one absolutely.

What exactly is an "interesting order" and how does the optimizer decide which orders qualify?

An interesting order is any sort order on a subset's output that some later operation could exploit to avoid an explicit sort: the columns in an `ORDER BY` or `GROUP BY` clause, or the join key of any relation not yet included that might use sort-merge join. The optimizer computes this set once, from the query's syntax, before running the DP, typically a small set (often under five distinct orders even for complex queries). During DP, for each subset it keeps the overall cheapest plan plus the cheapest plan achieving each interesting order, pruning any plan that is both more expensive and no better ordered than another candidate for the same subset.

Does the optimizer really try every join algorithm for every split?

Yes, in the classical formulation: for every candidate split of a subset, the optimizer costs hash join, sort-merge join, and (for small inputs or when indexes make it attractive) nested-loop join, then keeps only the cheapest result — or, if interesting orders are relevant, keeps additional order-preserving alternatives too. In practice, many of these are pruned quickly using rule-of-thumb bounds, for example skipping nested-loop join entirely when both inputs are large and unindexed, since its cost formula makes it a near-certain loser and evaluating it wastes time without changing the outcome.

Is the optimizer's chosen plan actually the fastest possible plan?

Not necessarily in the real world — only the cheapest under the cost model's estimates. The DP algorithm is provably optimal with respect to the numbers it is given: cardinalities, selectivities, and per-operator cost formulas from Cost Estimation for Query Plans. If those estimates are inaccurate (stale statistics, correlated predicates treated as independent, skewed data poorly summarized by histograms), the mathematically optimal plan under the model can still perform poorly in practice. This is why production systems pair DP with runtime feedback mechanisms, plan hints, and adaptive re-optimization in some engines, rather than trusting the static estimate blindly.

Why is the complexity O(3ⁿ) rather than the more commonly cited O(2ⁿ)?

`O(2ⁿ)` counts the number of subsets, which is indeed the size of the DP table. But computing each subset's entry requires iterating over all ways to split it into two disjoint nonempty parts, and summing the number of such splits across all subsets — using the fact that each of the `n` relations independently falls into the left part, right part, or outside the current subset entirely — gives exactly `3ⁿ` total split-evaluations. So `O(2ⁿ)` describes the space needed to store results, while `O(3ⁿ)` describes the time spent generating them, and both are standard, correctly derived bounds rather than approximations.

How does GEQO (genetic query optimization) in PostgreSQL relate to this DP algorithm?

GEQO is PostgreSQL's fallback when the number of joined relations exceeds a configurable threshold (default 12), where exact DP's `O(3ⁿ)` cost becomes impractical. It represents join orders as chromosomes, evaluates their cost with the same cost model DP would use, and evolves a population of candidate orders via mutation and crossover across generations, converging toward a low-cost — but not provably optimal — plan in polynomial-ish time. It trades DP's optimality guarantee for scalability, illustrating the exact tradeoff discussed for large `n`: exhaustive correctness versus tractable search.

Do NoSQL or non-relational systems need a query optimizer like this?

Systems with rich declarative query languages and multiple valid execution strategies — document stores with aggregation pipelines, graph databases with pattern-matching queries, and distributed SQL engines over non-relational storage — face the same fundamental problem whenever more than one join or access order is possible. Purely key-value stores with no join capability largely sidestep this issue since there is only one access path. Wherever true joins across multiple data sources exist, whether the system calls itself relational or not, a version of cost-based plan enumeration under the same combinatorics is either present or badly needed.

Before this