How the Cost-Based Optimizer Chooses a Plan
For any non-trivial query there are many ways to get the answer — different scan methods, join orders, and join algorithms. The planner/optimizer (src/backend/optimizer/) enumerates candidate paths, estimates the cost of each, and picks the cheapest. It does not try every possibility exhaustively; it prunes aggressively.
The one thing to understand first
For any non-trivial query there are many ways to get the answer — different scan methods, join orders, and join algorithms. The planner/optimizer (src/backend/optimizer/) enumerates candidate paths, estimates the cost of each, and picks the cheapest. It does not try every possibility exhaustively; it prunes aggressively.
The optimizer is a pure function: selectivity in, cost out. It almost always picks the right plan given accurate row counts, so plan debugging is not about outsmarting the planner — it is about finding the one row estimate that is wrong.
Paths and the cheapest-path principle
For each relation the planner builds Path objects — one per viable access method (seq scan, each usable index, bitmap). Paths carry a cost and useful properties such as sort order. At each level the planner keeps only the cheapest path for each distinct useful property (e.g. cheapest unordered, cheapest sorted by X). This pruning is what keeps planning tractable.
Where the cost numbers come from
Cost estimation needs selectivity: what fraction of rows a predicate keeps. This comes from the statistics collected by ANALYZE into pg_statistic (viewed via pg_stats):
- null fraction and n_distinct (number of distinct values).
- Most Common Values (MCVs) and their frequencies — used for equality on skewed columns.
- A histogram of value boundaries — used for range predicates.
For WHERE x = 'foo' the planner checks the MCV list; if present it uses the exact frequency, otherwise it assumes uniform distribution over the non-MCV distinct values. This is why skewed data with a too-small MCV list produces bad estimates.
The join search
Join ordering dominates plan quality. The planner uses dynamic programming (join_search_one_level in allpaths.c): it finds best paths for every single relation, then every pair, then every triple, reusing sub-results. The number of combinations grows fast, so beyond geqo_threshold (default 12 relations) PostgreSQL switches to the Genetic Query Optimizer (GEQO), which searches the space heuristically rather than exhaustively.
Choosing a join algorithm
For each candidate join the planner costs three algorithms:
- Nested Loop — for each outer row, probe the inner. Cheap when the outer side is tiny or the inner has a selective index. Cost explodes if the outer is large.
- Hash Join — build a hash table on the smaller side, probe with the larger. Great for big, unsorted, equality joins; needs
work_memto hold the hash table or it spills to disk. - Merge Join — sort both inputs (or use already-sorted index output) and merge. Wins when inputs are already ordered or for large range joins.
Why estimates matter more than the algorithm
The planner almost always picks the right algorithm given accurate row counts. The failures happen when a row estimate is off by orders of magnitude — it then picks a nested loop expecting 5 inner rows but gets 5 million. The fix is rarely a hint (PostgreSQL has none by design); it is better statistics.
Layer 3 — Watch it happen on your own database
-- See the raw inputs the planner uses
SELECT attname, null_frac, n_distinct, most_common_vals
FROM pg_stats WHERE tablename = 'events' AND attname = 'tenant_id';
-- Raise resolution on a skewed column
ALTER TABLE events ALTER COLUMN tenant_id SET STATISTICS 1000;
ANALYZE events;
-- Teach the planner about correlated columns
CREATE STATISTICS ev_corr (dependencies, ndistinct)
ON tenant_id, status FROM events;
ANALYZE events;Run EXPLAIN on a query filtering tenant_id and status together before and after creating the extended statistics: the estimated row count snaps closer to reality because the planner now knows the columns are correlated instead of multiplying their selectivities as if independent. That single change often flips a disastrous nested loop into a hash join.
Layer 4 — The levers this hands you
random_page_cost— lower it (e.g. 1.1) on SSDs so the planner stops over-penalising index scans.effective_cache_size— tell the planner how much memory is effectively available for caching; higher values favour index scans.work_mem— too low forces hash/sort spills, making the planner avoid those plans.SET STATISTICS/ extended statistics — raise resolution on skewed or correlated columns where the default 100 buckets mislead.geqo_threshold— controls when the exhaustive join search gives way to the genetic optimizer; raise it if you have many-way joins that plan poorly.
Layer 5 — What an Oracle DBA should expect vs what they get
Both are cost-based optimizers, but the surrounding machinery is very different:
- No hints, ever (in core). Oracle's culture of
/*+ ... */hints does not exist here. You shape plans through statistics, indexes, and GUCs — the discipline is "fix the estimate," not "pin the plan." - Extended statistics instead of column groups + dynamic sampling. Oracle handles correlation with column-group stats and dynamic sampling; PostgreSQL uses
CREATE STATISTICS(dependencies / ndistinct / MCV) — same goal, explicit and per-combination. - GEQO for many-way joins. Oracle has its own join-order heuristics and adaptive plans; Postgres switches to a genetic search past
geqo_thresholdrelations rather than using adaptive re-optimization. - No SQL Plan Management. There are no baselines or profiles in core; plan stability comes from stable, well-targeted statistics (and optionally
pg_hint_planif you truly need hints).
Key takeaway
The optimizer builds Path objects per relation, keeps only the cheapest path per useful property, and uses dynamic programming (or GEQO past 12 relations) to order joins — all driven by selectivity estimates from pg_statistic. Get the row counts right and the planner gets the plan right. When a plan is bad, find the node whose estimate is wildly off and fix its statistics; reach for GUCs like random_page_cost only after the numbers are trustworthy.
Interview lens
How this shows up in interviews
What an interviewer is actually testing, the same mechanism seen from three roles, and the gap between a shallow answer and one that shows real depth.
What the interviewer is testing
Whether you understand the planner enumerates paths and picks by estimated cost (not fixed rules), and which levers move its choices.
Questions you should be able to answer
- ▸How does Postgres choose between a sequential scan and an index scan?
- ▸The planner picked a 'worse' plan — how do you debug that?
- ▸What do random_page_cost and effective_cache_size actually do?
Database engineer
You calibrate cost constants to your hardware — random_page_cost on SSD, effective_cache_size to real cache — so estimated cost maps to reality.
Software engineer
You know disabling a plan type (enable_seqscan=off) is a diagnostic, not a fix; the real fix is statistics or cost calibration.
Feature builder
You write sargable predicates the planner can estimate well rather than fighting it with hints.
Shallow answer
'The optimizer picks the fastest plan.' Treats it as an oracle instead of a cost model over estimates.
Answer that shows depth
The planner enumerates candidate paths — scan methods, join orders, join algorithms — estimates each with a cost model (I/O plus CPU-per-tuple, using constants like seq_page_cost/random_page_cost and effective_cache_size applied to row estimates from statistics), and picks the cheapest, pruning aggressively and switching to GEQO when there are many joins. A 'wrong' plan almost always means the row estimate or the cost constants don't match reality, so the fix is better statistics or calibrated constants, not forcing a plan.
Pro lab notebook
The captured run behind every query
Every SQL query above is real and the source citations are verified. Pro unlocks the captured psql output — each query re-run against a real PostgreSQL 17.10 lab, with its verbatim result.
1 real lab transcript for this lesson
The explanation and the SQL above are free — so are the verified source citations. What Pro unlocks is the proof: every query in How the Cost-Based Optimizer Chooses a Plan re-run against a real PostgreSQL 17.10 instance, with its verbatim psql output captured line for line.
What Pro unlocks here
- Every query in this lesson re-run against a real PostgreSQL 17.10 lab
- The verbatim psql output — page headers, row versions, plan nodes, catalog rows
- The corrected form of any query the source lesson got wrong, re-captured
- A copy-ready notebook you can replay yourself to confirm every claim
How this was verified
This explanation is anchored to PostgreSQL's source tree — the files linked above, on the REL_17_STABLE branch — and cross-checked against the official documentation for versions 15 through 18. Every SQL query shown was executed and its output captured in a throwaway PostgreSQL 17.10 lab; where a query needed correcting to run, that correction is noted inline.
Official documentation: §20.7.2 Planner Cost Constants