Query Planner
// the problem
SQL is declarative: you say what rows you want, never how to get them. Something has to invent the how — which index (if any) to use, which order to join tables, which algorithm for each step. That something is the planner, and it works like an economist: it estimates the cost of many possible plans and picks the cheapest. Change the data and the same query can get a completely different plan.
You already watched this happen live in the B-tree lesson — a query flipping from a Seq Scan to an Index Scan. Now let's see why the planner flips, and how it decides everything else.
Cost is an estimate, in abstract units
The planner doesn't time anything. It assigns each plan a cost in
arbitrary units, anchored on a few constants (Postgres defaults): reading a page
sequentially costs 1.0 (seq_page_cost), a random page read costs 4.0
(random_page_cost), and touching a tuple in the CPU costs ~0.01. Every
EXPLAIN line shows cost=startup..total rows=… width=…:
startup is the cost before the first row appears; total is for all rows;
rows is the planner's estimate of how many rows come out; width is
their average byte size. The planner sums costs like these across a whole plan
tree and compares totals.
Those units aren't a black box — you can recompute the number yourself. A
full sequential scan's cost is simply pages read × seq_page_cost + rows
processed × cpu_tuple_cost. Postgres records every table's page and row counts
in pg_class, so the arithmetic is right there:
derived equals the total cost in the EXPLAIN line exactly — that's
literally the formula the planner ran. Every plan node has a formula like this;
the planner just adds them up the tree and keeps the cheapest grand total.
Why the plan flips: the cost crossover
Here is the core decision, distilled. A Seq Scan reads every page no matter what, so its cost is flat. An Index Scan pays per matching row (a random heap fetch each), so its cost rises with the number of matched rows. Drag the slider and watch which line is lower — the planner always picks the lower one.
planner picks: Index Scan — index cost 1201 beats 2000
The lines cross at a low selectivity (around half a percent here): an index only pays off when a query matches a small fraction of the table. Ask for much more and reading everything in order is cheaper. Postgres agrees, live — a tiny match uses the index, a big one falls back to a sequential (or bitmap) scan:
// note · the bitmap scan is the middle ground
Between “a few rows” and “most of the table” sits the Bitmap Heap Scan: use the index to collect all matching row locations, sort them, then read the heap pages in physical order — fewer random I/Os than a plain index scan, less waste than a full seq scan.
Where the row estimates come from
Every cost rests on an estimate of how many rows a step produces — and a
wrong row estimate poisons every cost above it in the tree. Those estimates
come from statistics gathered by ANALYZE and stored in pg_statistic
(readable through the pg_stats view). For each column Postgres keeps: how many
distinct values there are, the most common values with their frequencies,
and a histogram dividing the rest into equal-population buckets. Look at the
exact stats it holds for orders.amount:
To estimate WHERE amount < 50, the planner walks that histogram, works out what
fraction of values fall below 50, and multiplies by the row count. Compare its
guess to reality with EXPLAIN ANALYZE — estimated rows= right next to
actual … rows=:
Watch statistics go stale
Here's the failure mode that wrecks real production queries. Load a table, analyze it, then pour in 50,000 new rows without re-analyzing — and watch the planner keep believing there's just one matching row when there are now fifty thousand:
A 50,000× miss. In a real query that estimate would steer the planner into a catastrophic plan — say a nested loop it expects to run once, actually run fifty thousand times. Refresh the statistics and watch the estimate snap back:
// gotcha · stale statistics are the #1 cause of bad plans
When estimated and actual row counts diverge wildly, the planner is flying blind —
almost always because statistics went stale after a big data change. The fix is
usually just ANALYZE (autovacuum normally runs it for you). A planner with good
stats picks good plans; a planner with bad stats can't, no matter how sound
its cost model is.
Joining tables: three algorithms
When a query joins tables, the planner also chooses how to match rows. There are three strategies, each best in a different regime:
- Nested Loop — for each row on the outer side, look up matches on the inner
side. Naively that's O(N × M), but with an index on the inner side it drops to
O(N × log M) — so it's cheap only when the outer side is small (or the
join is very selective). It's also the only option for non-equality joins
(
<,>, range). - Hash Join — scan the smaller side once to build a hash table, then scan the
larger side once and probe it: O(N + M), equality only. The catch: the
hash table must fit in
work_mem, or it spills to disk in batches (slower). - Merge Join — walk both sides in sorted order and zip them together: O(N + M) to merge, plus O(N log N) first for any side that isn't already sorted. A bargain when an ordered index provides the sort for free, and it streams arbitrarily large inputs without a big hash table.
Postgres picks by cost. Here it chooses a hash join; disable it and watch the planner fall back to its next-cheapest option:
// why it matters · enable_* flags are for learning, not production
Toggling enable_hashjoin, enable_seqscan, etc. is a great way to see the
alternative the planner rejected and compare costs. But they're blunt
instruments — don't leave them off in production. If the planner chooses
badly, fix the cause (statistics, indexes, cost constants), not the symptom.
Join order is a search problem
A join of N tables can be arranged in a huge number of orders, and each order has different costs. The planner searches this space for the cheapest arrangement — exhaustively for a handful of tables, and via a genetic algorithm (GEQO) once there are too many to enumerate. This is why adding one more joined table can occasionally change a plan dramatically.
Reading a plan
EXPLAIN shows the estimated plan; EXPLAIN (ANALYZE, BUFFERS) actually runs
it and adds the truth. Read a plan inside-out — the deepest, most-indented
node runs first and feeds its parent. Four things to watch:
actual rowsvs estimatedrows— a big gap means bad statistics, and it's the first thing to check on a slow query (you just saw why).loops— how many times a node ran. UnderANALYZE,actual timeandrowsare reported per loop, so a node showingrows=1 loops=50000really produced 50,000 rows across 50,000 executions — multiply to get the true total. A surprisingly largeloopsis the fingerprint of a nested loop gone wrong.Buffers—shared hit= served from cache,read= fetched from disk; the ratio tells you how cache-friendly the plan is.Rows Removed by Filter— rows the node examined and threw away; a large count often means the right index is missing.
Your turn
Uses orders and customers from the first cell.
The join below uses a Hash Join by default. Force the planner to use a Nested Loop instead, and prove it with EXPLAIN.
Return the 3 customers with the most orders: their name and order count, highest first.
// what you now understand
- 01The planner turns declarative SQL into an execution plan by estimating each candidate plan's cost and picking the cheapest.
- 02Cost is abstract units (seq_page_cost=1, random_page_cost=4, ~0.01 per tuple), never measured time. A seq scan's cost is literally relpages×1 + reltuples×0.01 — you can derive it from pg_class and it matches EXPLAIN exactly.
- 03Seq scan cost is flat; index scan cost rises with matched rows, so an index only wins for a small fraction (bitmap scan covers the middle).
- 04Row estimates come from pg_stats (n_distinct, most-common-values, histogram) gathered by ANALYZE. After a big data change without ANALYZE, an estimate can be off by 50,000× and steer the planner into a disastrous plan — stale stats are the #1 cause of bad plans.
- 05Joins: nested loop O(N×M) or O(N×log M) with an index (small outer / non-equality), hash O(N+M) needing work_mem (big unsorted equality), merge O(N+M) if sorted else +O(N log N) (ordered indexes / huge inputs). Chosen by cost.
- 06Join order is a search problem — exhaustive for few tables, GEQO for many.
- 07Read plans inside-out. actual time/rows are per-loop, so multiply by loops; a huge estimate-vs-actual gap or loops count is the red flag.
// self-test
A query matches 90% of a large indexed table. What will the planner most likely choose, and why?
// self-test
EXPLAIN ANALYZE shows a node with estimated rows=5 but actual rows=500,000. What's the most likely problem?
// go deeper
- Using EXPLAIN (official docs) — reading plans, costs, ANALYZE/BUFFERS
- Planner Cost Constants — seq_page_cost, random_page_cost, and friends
- How the Planner Uses Statistics — pg_statistic, selectivity estimation
- Genetic Query Optimizer — searching large join-order spaces