πŸ“š Study Notes / Home / DBMS / Session 11
Session 11 Β· Query Optimization

How a database turns your SQL into the fastest possible plan

You write a short, simple SQL query β€” and behind the scenes the database has to choose how to actually run it, out of thousands of possible ways. Some of those ways finish in a millisecond; others would run for hours. The part of the database that makes this choice is the query optimizer, and it's one of the most beautiful pieces of engineering in all of computer science. We assume you've studied none of this before. Each topic starts with a tiny everyday story, then we build up the real machinery with worked examples. Take it slow.

⏱ 20 min readπŸ“– 4 topics

1 Why optimization matters


Explain like I'm 5

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.

The one big idea

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 typeWhat it describesExample
Logical planThe 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 planThe 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.

πŸ“
SQL text
Your declarative query
β†’
🌳
Parse
Build a logical plan (relational algebra)
β†’
πŸ”€
Rewrite
Apply equivalence rules to reshape it
β†’
πŸ’°
Cost
Estimate cost of physical alternatives
β†’
πŸ†
Pick
Choose the cheapest physical plan
β†’
βš™οΈ
Execute
Run it (Session 10)
Concrete example β€” same answer, very different cost

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.

Why not just try every plan and time it?

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.

Recap SQL is declarative: you say what, the database decides how. One query has many equivalent plans with hugely different costs. The optimizer turns a query into a logical plan (which operations), reshapes it, then chooses a physical plan (which algorithms, what order) by estimating cost β€” all before running a single row.

2 Cost estimation & selectivity


Explain like I'm 5

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:

StatisticMeaningUsed 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 / maxThe smallest and largest value in a column.Range filters (>, <).
Null fractionWhat share of rows are NULL.Adjusting counts.
HistogramA bar chart of how values are distributed across buckets.Handling skewed / uneven data.
Why histograms matter β€” skew

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

PredicateDefault selectivity estimateReasoning
col = value1 / (number of distinct values)Assume each distinct value is equally likely.
col > value (range)fraction of the min–max span above valueUse min/max or histogram buckets.
A AND Bsel(A) Γ— sel(B)Assume A and B are independent.
A OR Bsel(A) + sel(B) βˆ’ sel(A)Γ—sel(B)Inclusion–exclusion.
Worked example β€” estimating rows step by step

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.005
  • country = '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.

Why cardinality estimation is genuinely hard

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.

Key takeaway

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.

Recap The optimizer scores plans by cost, whose main ingredient is the number of rows each step yields. It estimates rows via selectivity (fraction passing a predicate) using stored statistics and histograms. The estimate is fragile because of data skew and column correlation β€” and small errors compound, which is why bad plans happen.

3 Join ordering


Explain like I'm 5

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)
3612
51201,680
840,320~17,000,000
12~479,000,000billions+

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.

1️⃣
Single tables
Best access path for each table alone
β†’
2️⃣
Pairs
Best plan for every 2-table subset
β†’
3️⃣
Triples
Reuse best pairs to build best triples
β†’
🏁
All n
Best plan for the full set
Worked example β€” why order changes cost

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:

ApproachHow it worksPros / 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 heuristicRepeatedly join the cheapest available pair next.Very fast; may miss the true best order.
Genetic / randomized searchEvolve/mutate candidate orderings (PostgreSQL's GEQO does this for many-table joins).Scales to huge joins; result is approximate and can vary.
In practice

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.

Key takeaway

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.

Recap With many tables, the number of join orders explodes (n! just for left-deep, far more for bushy). Order matters enormously because it sets intermediate-result sizes. System R solved this with dynamic programming over left-deep trees; modern systems switch to heuristic/genetic search when there are too many tables to enumerate.

4 Rule-based transformations


Explain like I'm 5

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.

An index isn't always the win

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

Worked example β€” rewriting a plan

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:

PieceMeaning
cost=15.50..320.75Estimated startup cost .. total cost, in the planner's abstract cost units (not seconds).
rows=50The estimated rows out of this node β€” the cardinality estimate from Topic 2.
width=40Estimated average row size in bytes (smaller after projection pushdown).
Seq ScanReads the whole table β€” chosen for Orders (no useful index here).
Index ScanUsed the customers_city_idx to fetch only London rows β€” predicate pushdown + index access.
Hash JoinThe physical join algorithm chosen (from Session 10).
EXPLAIN vs EXPLAIN ANALYZE

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.

Recap Rule-based transformations rewrite a plan into an equivalent but cheaper shape: predicate pushdown (filter early), projection pushdown (drop columns early), constant folding (precompute constants), and choosing indexes over full scans when selective. You can see the optimizer's final choices β€” and spot bad estimates β€” by reading 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

Papers, docs & deep dives