Module A-6·26 min read

The planner is a cost-based optimizer. Every wrong plan has a root cause in statistics, configuration, or schema design.

Written by Jatin Jain Saraf · Architect of SupraScan

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 EXPLAIN output, 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:

  1. Generating candidate plans using dynamic programming
  2. Estimating the cost of each plan using a statistical model
  3. 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:

seq_page_cost    = 1.0   # cost of reading one page sequentially
random_page_cost = 4.0   # cost of reading one page randomly (I/O seek)
cpu_tuple_cost   = 0.01  # cost of processing one row
cpu_index_tuple_cost = 0.005  # cost of processing one index entry
cpu_operator_cost = 0.0025    # cost of evaluating one operator

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.

# For SSD storage
random_page_cost = 1.5
 
# For NVMe with large shared_buffers (mostly cache hits)
random_page_cost = 1.1

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
Sort  (cost=12453.84..12703.84 rows=100000 width=76)
  ->  Seq Scan on transactions  (cost=0.00..5330.00 rows=100000 width=76)

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:

SELECT * FROM transactions ORDER BY timestamp DESC LIMIT 10;

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.

SELECT
  attname,
  n_distinct,
  correlation,
  null_frac,
  avg_width,
  most_common_vals,
  most_common_freqs,
  histogram_bounds
FROM pg_stats
WHERE tablename = 'transactions'
  AND attname = 'status';

For a status column with values ['confirmed', 'pending', 'failed']:

n_distinct:        3
null_frac:         0
most_common_vals:  {confirmed, pending, failed}
most_common_freqs: {0.952, 0.039, 0.009}

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:

n_distinct:       -0.95         ← negative means ~95% of rows have distinct values
correlation:       0.834
histogram_bounds: {18000000, 18050000, 18100000, ...}

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.

-- Check current statistics target for a column
SELECT attstattarget FROM pg_attribute
WHERE attrelid = 'transactions'::regclass
  AND attname = 'block_height';
-- -1 means use default_statistics_target
 
-- Increase for a column with complex distribution
ALTER TABLE transactions
ALTER COLUMN block_height SET STATISTICS 500;
 
ANALYZE transactions;

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.

-- city and zip_code are not independent — every city has specific zip codes
SELECT count(*) FROM addresses WHERE city = 'San Francisco' AND zip_code = '94105';
-- Planner estimate: P(city='SF') * P(zip='94105') * row_count (assumes independence)
-- Actual: all '94105' rows are in 'SF' — the estimate is orders of magnitude off

Extended statistics capture multi-column dependencies:

-- Create statistics that capture city/zip correlation
CREATE STATISTICS addr_city_zip (dependencies, ndistinct, mcv)
ON city, zip_code FROM addresses;
 
ANALYZE addresses;
 
-- Now the planner uses the joint distribution instead of assuming independence

Three kinds of extended statistics:

  • dependencies — captures functional dependencies (zip implies city)
  • ndistinct — captures combined distinct count for GROUP BY estimates
  • mcv — 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.

Seq Scan on transactions  (cost=0.00..285432.00 rows=10000000 width=148)
  Filter: (block_height > 18000000)
  Rows Removed by Filter: 523847

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

Index Scan using idx_transactions_block_height on transactions
  (cost=0.57..8.59 rows=1 width=148) (actual time=0.032..0.034 rows=1 loops=1)
  Index Cond: (block_height = 18500050)
  Buffers: shared hit=4

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:

Bitmap Heap Scan on transactions  (cost=1823.44..18234.55 rows=90000 width=148)
  Recheck Cond: (block_height BETWEEN 18500000 AND 18510000)
  ->  Bitmap Index Scan on idx_transactions_block_height
        Index Cond: (block_height BETWEEN 18500000 AND 18510000)

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.

Index Only Scan using idx_transactions_composite on transactions
  (cost=0.57..4.59 rows=1 width=24) (actual time=0.018..0.019 rows=1 loops=1)
  Index Cond: (block_height = 18500050)
  Heap Fetches: 0

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.

Nested Loop  (cost=0.57..16.62 rows=5 width=200)
  ->  Index Scan on blocks b  (cost=0.42..8.44 rows=1 width=52)
        Index Cond: (b.height = 18500050)
  ->  Index Scan on transactions t  (cost=0.57..8.12 rows=5 width=148)
        Index Cond: (t.block_height = b.height)

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 & Register

See it in production

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.