The planner is a cost-based optimizer. Every wrong plan has a root cause in statistics, configuration, or schema design.
Module 6 — Query Planning and Execution: How Postgres Decides What to Do With Your Query
What this module covers: The query planner is a cost-based optimizer. It does not know the right answer — it estimates the cost of many possible plans and picks the cheapest one based on statistics. Every wrong plan has a root cause: stale statistics, a bad cardinality estimate, a misconfigured cost parameter, or a schema design that gives the planner no good options. This module gives you the mental model to read any
EXPLAINoutput, identify what went wrong, and fix it.
The Planner's Job
When you execute a query, the planner receives a query tree (the semantically validated parse output from Module 0) and must produce an execution plan — a tree of physical operations that, when executed, returns the correct result.
For a non-trivial query, hundreds or thousands of valid execution plans exist. Different join orders, different index choices, different aggregation strategies — all produce the same correct result with wildly different costs.
The planner's job is to find the plan with the lowest estimated cost, fast enough that planning time does not become a bottleneck itself.
It does this by:
- Generating candidate plans using dynamic programming
- Estimating the cost of each plan using a statistical model
- Emitting the lowest-cost plan to the executor
Every word in step 2 is load-bearing: estimating (not measuring), based on a statistical model (not reality). Plan quality is bounded by statistics quality. This is the single most important thing to understand about the Postgres planner.
The Cost Model
Cost Units
Postgres measures plan cost in arbitrary cost units that approximate I/O and CPU work. The absolute numbers are meaningless — only relative comparisons between plans matter.
The cost parameters that define what one unit means:
random_page_cost = 4.0 is the most important tuning parameter for most systems.
The default of 4.0 was calibrated for spinning disks where random reads cost ~4x more than sequential reads. On SSDs, random reads cost 1.1–2x sequential reads. On NVMe with a warm OS cache, they're nearly equal.
If your storage is SSD and random_page_cost is still at 4.0, the planner overestimates the cost of index scans and chooses sequential scans when it shouldn't.
This single change often fixes "the planner won't use my index" problems on modern hardware.
Startup vs Total Cost
Every plan node reports two costs: (startup_cost..total_cost).
- Startup cost: cost before the first row is returned
- Total cost: cost to return all rows
The Sort node has high startup cost because it must materialize all input rows before returning any output. The Seq Scan has zero startup cost — it starts returning rows immediately.
For LIMIT queries, the planner prefers plans with low startup cost because only the first N rows need to be produced:
The planner may choose an index scan on timestamp (which delivers rows in sorted order immediately, low startup cost) over a sequential scan + sort (which has high startup cost from sorting, even if lower total cost).
This is the LIMIT optimization: low startup cost beats low total cost when not all rows are needed.
Statistics: The Foundation of Every Estimate
What Postgres Collects
ANALYZE (or autovacuum's analyze phase) collects statistics about each column and stores them in pg_statistic. The human-readable view is pg_stats.
For a status column with values ['confirmed', 'pending', 'failed']:
The planner knows: status = 'confirmed' matches 95.2% of rows, status = 'pending' matches 3.9%. This is how it decides whether an index on status is worth using for a given query.
For numeric columns like block_height:
The histogram divides the value range into equal-frequency buckets. Each bucket boundary is a value where roughly 1/statistics_target of rows fall below it. The planner uses linear interpolation within buckets.
statistics_target: The Detail Level
default_statistics_target (default: 100) controls how many histogram buckets and most-common-values are collected. More buckets = better estimates for selective predicates = better plans = slower ANALYZE.
Increase statistics_target for columns where:
- The planner makes bad cardinality estimates (check estimated vs actual rows in EXPLAIN ANALYZE)
- The column has many distinct values with uneven distribution
- The column is used in range predicates with wide selectivity variance
The cost: ANALYZE takes longer and pg_statistic uses more space. The benefit: better plans.
Extended Statistics: Multi-Column Correlations
The planner assumes columns are statistically independent. When they are not, estimates for multi-column predicates are wrong.
Extended statistics capture multi-column dependencies:
Three kinds of extended statistics:
dependencies— captures functional dependencies (zip implies city)ndistinct— captures combined distinct count for GROUP BY estimatesmcv— most-common-value combinations for complex predicate estimates
Extended statistics is one of the most underused planner improvements available.
Scan Nodes: How Rows Are Retrieved
Sequential Scan
Reads every page of the heap in order. Always correct. Often the right choice for large result sets or low-selectivity predicates.
Rows Removed by Filter tells you how much work the filter is doing post-scan. High numbers with a filter that could be served by an index = potential missing index.
Index Scan
Traverses the B-tree to find matching TIDs, then fetches heap pages in TID order (random access).
Buffers: shared hit=4 — 4 page reads, all from shared_buffers cache. 3 B-tree pages + 1 heap page. This is the optimal case.
Bitmap Index Scan + Bitmap Heap Scan
For queries matching many rows, a bitmap scan is more efficient than a pure index scan:
The bitmap index scan builds an in-memory bitmap of matching heap pages. The bitmap heap scan then reads those pages in heap order (reducing random I/O). When the bitmap is too large for work_mem, it becomes lossy — the Recheck Cond re-evaluates the predicate at the heap level.
Index Only Scan
No heap access — all data comes from the index leaf pages.
Heap Fetches: 0 — perfect. VM bits are set, no heap reads needed.
Join Strategies
Joins are where plan complexity explodes. For N tables, there are N! possible join orders, and each join can use one of three strategies.
Nested Loop Join
For each row in the outer relation, scan the inner relation for matching rows.
Cost: O(outer_rows × inner_lookup_cost).
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion