1 Why optimization matters
Imagine you ask a friend: "Can you bring me the red book, the blue pen, and my jacket?" You told them what you want, but not how to fetch it. A lazy friend might walk to your room three separate times. A smart friend grabs all three in one trip, picking the shortest route. Same request, very different effort. A database is the smart friend: you tell it what data you want, and it figures out the cleverest route to get it.
In SQL you write what you want, not how to get it. SQL is a declarative language β you describe the result ("give me all customers in London who spent over $1000"), and the database decides the actual steps. This is different from a language like Python, where you'd write the exact loops and lookups yourself (that style is called imperative).
The catch: there are usually many different step-by-step procedures that all produce the exact same answer. We call each one a plan (or query plan / execution plan). Two plans can return identical rows yet differ in speed by factors of thousands or millions. The piece of the database that picks a good plan is the query optimizer.
One SQL query β many equivalent plans β wildly different costs. The optimizer's whole job is to search through those plans and pick a fast one β without running them. It estimates costs on paper and bets on the cheapest.
Logical plans vs physical plans
Optimization happens in two layers. First the database figures out what operations to do; then it figures out how to do each one. These are two different kinds of plan:
| Plan type | What it describes | Example |
|---|---|---|
| Logical plan | The algebra: which operations and in what order, expressed abstractly (join, filter, project). It says what, not how. | "Join Orders with Customers, then filter to London, then keep two columns." |
| Physical plan | The concrete algorithm chosen for each logical step, plus access methods. It says how. | "Use a hash join, scan Orders with an index, filter with a sequential scan." |
A logical operation like "join" can be carried out by several physical operators β a nested-loop join, a hash join, or a sort-merge join (you met these in Session 10: Query Execution β Joins & Sorting). The optimizer's job is to start from the logical plan and choose the best physical operators and the best order, guided by cost estimates.
Suppose Orders has 10,000,000 rows and only 50 of them belong to customers
in London. Consider this query:
SELECT c.name, o.total FROM Orders o JOIN Customers c ON o.customer_id = c.id WHERE c.city = 'London';
Plan A (bad): join all 10,000,000 orders to all customers first, then throw away everyone not in London. You built a giant intermediate result just to discard 99.99% of it.
Plan B (good): first filter Customers down to the London ones, then join only those to their orders. You touch a tiny fraction of the data. Same rows out β possibly thousands of times faster in.
The optimizer's job is to notice that Plan B exists and choose it. We'll see how it does this (predicate pushdown) in Topic 4.
Because running even one plan can take minutes, and there can be billions of candidate plans. The optimizer must choose before executing, using cheap estimates. That's why the next topic β cost estimation β is the heart of the whole system.
2 Cost estimation & selectivity
Imagine planning a road trip before you drive it. You can't drive every possible route to see which is fastest β that would take forever. Instead you guess: "this road is about 50 miles, traffic is usually light, so maybe an hour." You use rough knowledge to estimate. The optimizer does the same: it can't run every plan, so it guesses how much work each one will be, using little summaries it keeps about your data.
To compare plans without running them, the optimizer assigns each one a cost β a single number estimating how much work it'll take (CPU + disk reads, roughly). The plan with the lowest estimated cost wins. The hardest, most important input to that cost is: how many rows will each step produce? Get the row counts right and the cost is usually right; get them wrong and the optimizer can pick a disastrous plan.
Statistics β what the database remembers about your data
The database keeps statistics about each table, gathered by periodically scanning (or sampling) it. The optimizer never looks at the actual data while planning β only these summaries. Common statistics:
| Statistic | Meaning | Used for |
|---|---|---|
| Row count (cardinality) | How many rows the table has. | Scaling every estimate. |
| Number of distinct values (NDV) | How many different values a column holds. | Estimating equality filters & joins. |
| Min / max | The smallest and largest value in a column. | Range filters (>, <). |
| Null fraction | What share of rows are NULL. | Adjusting counts. |
| Histogram | A bar chart of how values are distributed across buckets. | Handling skewed / uneven data. |
If you only know a column has 100 distinct values, you might assume each value is equally common.
But real data is skewed: a status column might be 95% "shipped" and
5% everything else. A histogram records the true shape (e.g. "this bucket of
values holds 40% of rows"), so a filter on a rare value is estimated as small and a filter on a common
value as large. In PostgreSQL you refresh these with ANALYZE.
Selectivity β what fraction survives a filter
The selectivity of a predicate (a condition in a WHERE
clause) is the fraction of rows that pass it β a number between 0 and 1. Multiply selectivity by
the table's row count and you get the estimated rows out. This estimated row count is called the
cardinality estimate.
Rules of thumb the optimizer uses (when it has no better information):
| Predicate | Default selectivity estimate | Reasoning |
|---|---|---|
col = value | 1 / (number of distinct values) | Assume each distinct value is equally likely. |
col > value (range) | fraction of the minβmax span above value | Use min/max or histogram buckets. |
A AND B | sel(A) Γ sel(B) | Assume A and B are independent. |
A OR B | sel(A) + sel(B) β sel(A)Γsel(B) | Inclusionβexclusion. |
Table Customers has 1,000,000 rows. The optimizer's stats say
the city column has 200 distinct values, and the
country column has 50 distinct values. Query:
SELECT * FROM Customers WHERE city = 'London' AND country = 'UK';
Step 1 β selectivity of each predicate.
city = 'London'β sel = 1 / 200 = 0.005country = 'UK'β sel = 1 / 50 = 0.02
Step 2 β combine (assume independence). sel(AND) = 0.005 Γ 0.02 = 0.0001.
Step 3 β estimated rows out. 1,000,000 Γ 0.0001 = 100 rows.
So the optimizer plans the rest of the query expecting roughly 100 rows here β which decides whether to use an index, which join algorithm to choose, and so on.
That tidy "Γ 0.0001" hid a big assumption: that city and
country are independent. They obviously aren't β
every London customer is in the UK! The true selectivity of the combined filter is just 0.005
(the city already implies the country), so the real answer is ~5,000 rows, not 100. The optimizer is off
by 50Γ. These correlation errors compound through joins, and a
single bad estimate can make the optimizer pick a plan that's orders of magnitude too slow. This is the
single biggest source of real-world query-plan disasters, and decades of research still haven't fully
solved it.
Cost is driven by estimated row counts. Row counts come from selectivity Γ table size, using statistics and histograms. Estimation is hard because real data is skewed and columns are correlated β and errors multiply as they flow up through a multi-table plan.
3 Join ordering
You have to introduce three friend-groups to each other at a party so everyone who should meet, meets. You could introduce group A to B first, then bring in C β or A to C first, then B. The order you do the introductions in changes how crowded each step gets. If you merge the two biggest, loudest groups first, you get a giant mob to manage. Do the small introductions first and it stays calm. Joining tables is exactly this: the order you combine them in decides how big the in-between piles get.
Most real queries join several tables. A join combines rows from two tables on a matching condition. When you join three or more tables, you must pick an order β and because joins produce intermediate results that feed the next join, the order massively changes how much work happens. Join ordering is the single most important decision the optimizer makes, because the intermediate result sizes can blow up.
The combinatorial explosion
How many orders are there? For n tables, the number of possible join orderings grows terrifyingly fast β far faster than even factorial when you also count the different tree shapes:
| Tables (n) | Left-deep orderings (n!) | All tree shapes (much larger) |
|---|---|---|
| 3 | 6 | 12 |
| 5 | 120 | 1,680 |
| 8 | 40,320 | ~17,000,000 |
| 12 | ~479,000,000 | billions+ |
You can't enumerate all of these for a 15-table query. So the optimizer needs both a way to limit the shapes it considers, and a clever search that avoids brute force.
Left-deep trees β taming the shapes
A join tree can be shaped many ways. The classic restriction is the left-deep tree: every join takes the running result so far on the left and joins in one base table on the right. It looks like a staircase.
Left-deep (preferred): Bushy (more shapes, rarely needed):
β β
/ \ / \
β D β β
/ \ / \ / \
β C A B C D
/ \
A B
Why left-deep? Because it lets the optimizer pipeline nicely (feed one join's output straight into the next without materializing huge tables) and it keeps the search space at n! instead of the astronomically larger set of all "bushy" trees. System R, the original optimizer, restricted itself to left-deep trees for exactly this reason.
Dynamic programming β the System R approach
The breakthrough from the 1979 System R paper (Selinger et al.) was to find the best order without trying every order, using dynamic programming (DP). The trick: the best way to join a big set of tables is built from the best ways to join its smaller subsets. So compute and remember the best plan for every subset, building up from small to large.
Join three tables: A (1,000,000 rows), B
(1,000,000 rows), and C (100 rows). Suppose joining AβB produces
5,000,000 rows (they match many-to-many), but AβC produces only
500 rows (C is tiny and selective).
Order 1: (A β B) β C
- A β B β build a 5,000,000-row intermediate (expensive, memory-heavy).
- Then β C β process 5,000,000 rows again. Total work β 10,000,000 row-operations.
Order 2: (A β C) β B
- A β C β only 500 rows (tiny intermediate).
- Then β B β join those 500 rows to B. Total work β a few thousand row-operations.
Same final answer, but Order 2 is roughly 1,000Γ cheaper. The whole game is: join the most selective things first so intermediates stay small. (Notice this is exactly why the cardinality estimates from Topic 2 are so important β the optimizer needs to know that AβC is small to choose Order 2.)
DP vs heuristics β when each is used
DP gives the provably best order it considers, but its cost grows like 2n, so it becomes too slow past roughly 10β15 tables. Beyond that, optimizers fall back to heuristics β fast rules of thumb that find a "good enough" order without guaranteeing the best:
| Approach | How it works | Pros / cons |
|---|---|---|
| Dynamic programming (System R) | Build best plan for every subset, bottom-up. | Optimal among considered plans; cost ~2n, limited to ~12β15 tables. |
| Greedy heuristic | Repeatedly join the cheapest available pair next. | Very fast; may miss the true best order. |
| Genetic / randomized search | Evolve/mutate candidate orderings (PostgreSQL's GEQO does this for many-table joins). | Scales to huge joins; result is approximate and can vary. |
PostgreSQL uses DP by default, but once a query joins more than geqo_threshold
tables (12 by default) it switches to its Genetic Query Optimizer (GEQO) to keep planning
time reasonable. So the same database uses both strategies depending on query size.
Join order dominates query cost because it controls the size of intermediate results, and a bad order can blow them up by orders of magnitude. The search space explodes combinatorially, so optimizers restrict shapes (left-deep trees) and search smartly with dynamic programming for small joins, falling back to heuristics for large ones.
4 Rule-based transformations
Imagine you're cleaning a giant bag of mixed LEGO to find just the red 2Γ4 bricks. The silly way: carry the whole heavy bag to your room, dump it out, then pick the red ones. The smart way: pick the red ones out first, right where the bag is, and only carry that tiny handful upstairs. Doing the throwing-away as early as possible means you carry far less. Databases follow rules like this to rearrange a query into a cheaper shape that gives the exact same answer.
Before (and during) cost-based search, the optimizer applies transformation rules: rewrites that change a plan's shape while guaranteeing the result stays identical. These come from the algebra of relations β operations that are provably equivalent. Unlike join ordering (which needs cost estimates), many of these are almost always good, so they're applied as rules. Here are the classics:
Predicate pushdown (filter early)
Predicate pushdown moves WHERE filters as close to the
raw tables as possible β before joins instead of after. Filtering first shrinks the data that the
expensive joins have to chew through. This is the LEGO trick, and it's exactly the "Plan A vs Plan B" from
Topic 1.
Projection pushdown (drop columns early)
Projection pushdown moves column-trimming (the SELECT
list) down so the database stops carrying columns it doesn't need. Narrower rows mean less memory and less
data shuffled between operators. If you only need name and
total, there's no reason to drag along 30 other columns through a join.
Constant folding (do the arithmetic once)
Constant folding evaluates constant expressions at planning time instead of
once per row. WHERE price > 10 * 100 becomes WHERE price >
1000 β computed once, not a million times. Related simplifications drop always-true conditions like
1 = 1 and short-circuit always-false ones.
Using indexes (the right access path)
An index (covered in earlier sessions) is a sorted lookup structure, like a book's index. Instead of a full sequential scan (reading every row), the optimizer can choose an index scan to jump straight to matching rows β but only when the filter is selective enough to be worth it. Choosing between a sequential scan and an index scan for each table is the access-path selection that the System R paper is literally named after.
If a filter matches most of the table (low selectivity for filtering purposes β say 80% of rows pass), an index scan can be slower than a plain sequential scan, because it jumps around the disk randomly for nearly every row. This is precisely why the optimizer needs the selectivity estimates from Topic 2 to decide. Rules reshape the plan; cost decides the access path.
Before / after β a worked transformation
The query:
SELECT c.name, o.total FROM Orders o JOIN Customers c ON o.customer_id = c.id WHERE c.city = 'London';
Before (naΓ―ve logical plan): join everything, then filter, then trim columns.
Ο (name, total) -- project: keep 2 columns (LAST) ββ Ο (city = 'London') -- filter (MIDDLE) ββ Orders β Customers -- join ALL rows first (FIRST, huge!)
After (optimized): push the filter down onto Customers, push projection down, then join only what survives.
Ο (name, total) ββ ( Orders ) β ( Ο city='London' (Customers) ) β filter applied BEFORE the join
Now the join only sees London customers (tiny), and only the needed columns flow through. Same rows
out, far less work in. The symbols above are relational algebra: Ο (sigma) =
filter/select, Ο (pi) = project, β = join.
Reading EXPLAIN β seeing the chosen plan
You don't have to guess what the optimizer picked. The EXPLAIN command prints
the chosen physical plan as an indented tree. In PostgreSQL:
EXPLAIN SELECT c.name, o.total FROM Orders o JOIN Customers c ON o.customer_id = c.id WHERE c.city = 'London';
A typical output looks like this (read it inside-out: the most indented nodes run first, feeding their parents):
Hash Join (cost=15.50..320.75 rows=50 width=40) Hash Cond: (o.customer_id = c.id) -> Seq Scan on orders o (cost=0.00..250.00 rows=10000 width=12) -> Hash (cost=15.00..15.00 rows=50 width=36) -> Index Scan using customers_city_idx on customers c (cost=0.42..15.00 rows=50 Filter: (city = 'London'))
How to read each node:
| Piece | Meaning |
|---|---|
cost=15.50..320.75 | Estimated startup cost .. total cost, in the planner's abstract cost units (not seconds). |
rows=50 | The estimated rows out of this node β the cardinality estimate from Topic 2. |
width=40 | Estimated average row size in bytes (smaller after projection pushdown). |
Seq Scan | Reads the whole table β chosen for Orders (no useful index here). |
Index Scan | Used the customers_city_idx to fetch only London rows β predicate pushdown + index access. |
Hash Join | The physical join algorithm chosen (from Session 10). |
EXPLAIN shows the plan with estimated rows and cost without running
the query. EXPLAIN ANALYZE actually runs it and shows the
real times and real row counts too. The most powerful debugging trick in all of SQL tuning:
compare the estimated rows= against the actual rows= β
a big gap reveals a bad statistic or a correlation the optimizer missed (Topic 2), which is usually the
root cause of a slow query.
EXPLAIN output inside-out.
β Putting it all together
You just learned how a database secretly transforms your tidy little SQL query into a fast, concrete execution plan. Here's the one-paragraph story connecting all four topics:
Because SQL is declarative, one query has many equivalent plans with
wildly different costs, and the optimizer must pick a good physical plan
from a logical plan without running anything (Topic 1). To compare plans it estimates
cost, driven mostly by row counts derived from selectivity and stored
statistics/histograms β an estimate made fragile by skew and correlation (Topic 2). The
most cost-critical decision is join order, where the search space explodes, so optimizers
restrict to left-deep trees and search with dynamic programming (System R)
or heuristics for big joins (Topic 3). Throughout, it applies always-good
rule-based transformations β predicate pushdown, projection pushdown, constant folding,
and index selection β and you can inspect the result with EXPLAIN (Topic 4). Then
the chosen plan is handed to the execution engine you met in Session 10.
Quick self-check
What's the difference between a logical plan and a physical plan?
A logical plan describes which operations to do (join, filter, project) abstractly; a physical plan describes how β the concrete algorithm for each step (e.g. hash join vs nested-loop join) and the access methods (seq scan vs index scan).
A column has 200 distinct values and the table has 1,000,000 rows. Roughly how many rows pass col = 'X'?
Selectivity β 1/200 = 0.005, so about 0.005 Γ 1,000,000 = 5,000 rows (assuming values are evenly distributed β a histogram would refine this if the data is skewed).
Why is cardinality estimation so error-prone?
Real data is skewed (values aren't equally common) and columns are correlated (e.g. city implies country), but the optimizer often assumes uniformity and independence. Errors then multiply as they propagate up through joins, sometimes by orders of magnitude.
Why do optimizers restrict join search to left-deep trees?
The full space of join-tree shapes (including bushy trees) is astronomically large. Left-deep trees keep the count at n!, pipeline efficiently, and were the search space used by System R's dynamic-programming optimizer.
What does predicate pushdown do, and why does it help?
It moves WHERE filters down to run before joins, close to
the base tables. Filtering early shrinks the data the expensive joins must process, often making the query
dramatically faster while returning the exact same rows.
You run EXPLAIN ANALYZE and see estimated rows=50 but actual rows=50000. What does that tell you?
The optimizer's cardinality estimate is badly off (likely stale statistics or hidden column
correlation). That bad estimate may have led it to pick a poor plan β refresh stats (e.g.
ANALYZE) or help it estimate better.
π References & Further Reading
Class material
- π Original course notes / handout (source sheet) β open the shared class material for this session.
- π DBMS Session 11 β Query Optimization (class handout).
Papers, docs & deep dives
- CMU 15-445 β Database Systems β the gold-standard free course; its query-planning and optimization lectures cover everything here in depth.
- Selinger et al., "Access Path Selection in a Relational Database Management System" (1979) β the foundational System R paper that introduced cost-based optimization and dynamic-programming join ordering.
- PostgreSQL Documentation β Using EXPLAIN β the official guide to reading query plans, costs, and row estimates in a real, widely-used database.
- PostgreSQL Documentation β Statistics Used by the Planner β how a production optimizer stores and uses statistics, histograms, and distinct-value counts.
- Use The Index, Luke! β a friendly, practical explainer on indexing and how the optimizer decides between index scans and full scans.