Query Plansplannerexecutorcost modelstatisticsseq scan

How a Query Executes: Planner and Executor

The planner enumerates ways to run a query, prices each with statistics and a cost model, and hands the cheapest to an executor that pulls rows through a tree of scan, join, sort and aggregate nodes.

▶ InteractiveInterview questionSee how this works internally →
Progress

SQL → plan

After parsing and rewriting, the planner has a query tree: which tables, which predicates, which joins, which aggregates. For each table it considers every access path — sequential scan, each usable index. For each join it considers every order and every algorithm. For sorts and aggregates it considers whether an input is already ordered. Each candidate gets a cost: an abstract number built from page reads (sequential = 1.0, random = 4.0) and per-row CPU work (0.01 per tuple, 0.0025 per operator).

The row counts that feed those costs come from statistics gathered by ANALYZE: rows per table, distinct values per column, most-common values, histograms. The planner never looks at the data itself while planning. If the statistics are stale, the plan is optimised for a table that no longer exists. That is why "the query got slow and nothing changed" is so often answered by running ANALYZE.

Plan selection
Query treeStatisticsAccess paths per tableJoin orders × algorithmsCost each candidateCheapest planExecutor
UserLLMAgentToolDataDecisionHumanGuardrail

Scan nodes

Seq Scan reads every page in order. Right for small tables and unselective predicates. Index Scan walks the index for matching entries and fetches each row from the table by pointer. Right for selective predicates. Index Only Scan answers from the index alone when every needed column is in it. Bitmap Index Scan + Bitmap Heap Scan collects matching row pointers from one or more indexes into a bitmap, sorts them by page, then reads the pages in physical order — the middle ground for medium selectivity, and how the planner combines two indexes for an OR.

Join nodes

Nested Loop: for each outer row, run the inner plan. O(outer × inner) if the inner is a scan, O(outer × log inner) if it is an index lookup — the index makes all the difference. Chosen when the outer side is small or the inner has an index on the join key. Hash Join: build a hash table on the smaller input, probe with the larger. O(n + m), needs an equality condition and memory for the build side; spills to disk in batches if the build exceeds work_mem. The default for large joins. Merge Join: both inputs sorted on the key, walked together. O(n + m) after sorting; wins when indexes already provide the order or the output must be sorted anyway.

You do not pick the algorithm. You enable good ones: an equality join condition (hash and merge need it), an index on the inner join column (makes nested loop cheap), and accurate statistics (so the planner picks the right one).

Sort, aggregate, limit

Sort materialises its whole input before emitting the first row — it is a pipeline breaker. In memory up to work_mem, then an external merge on disk. Avoided entirely when an index provides the order. HashAggregate groups by hashing, memory proportional to group count. GroupAggregate groups a sorted input in a stream. Limit stops pulling rows from its child once it has enough — which is only useful if the child can produce rows incrementally; a Limit over a Sort still sorts everything.

The executor is a tree of iterators: each node asks its child for the next row. That pull model is why LIMIT over an index scan stops early, why a Sort under it does not, and why the top of the plan is the last thing to run.

Key points

  • The planner costs candidates from statistics; it never looks at the data. Stale statistics produce plans for a table that no longer exists.
  • Scan nodes: Seq, Index, Index Only, Bitmap. Join nodes: Nested Loop, Hash, Merge. Pipeline breakers: Sort, HashAggregate.
  • You enable good plans with equality conditions, indexes on join keys, and fresh ANALYZE.
  • The executor pulls rows top-down; LIMIT stops early only when its child streams.

Read a query plan

Read a query plan
Eight plan shapes, each executed for real. Click any node: the panel shows what it estimated, what actually happened, and what the gap means.
cheapestSQL textParseRewriteCandidate plansCost estimationChosen planExecuteResult
EXPLAIN ANALYZE
SELECT count(*), round(avg(total), 2) FROM orders
(cost=86.01 rows=1) (actual rows=1 loops=1)
-> (cost=54.00 rows=3200) (actual rows=3200 loops=1)slowest
Nodes
2
Pages read
22
Aggregate
Estimated rows
1
Actual rows
1
Estimated cost
86.01
Time
1.800 ms

Reading 3,200 rows to produce one. No index avoids that — an aggregate over the whole table has to see the whole table (or a pre-computed rollup).

What it does

Read every page of the table in physical order and hand every row upwards.

When the planner picks it

No usable index, or the query needs most of the table anyway. For a full aggregate it is the correct plan and an index would be slower.

What it costs

Linear in table size, but sequential — the cheapest kind of IO per page. Look at “Rows Removed by Filter”: a scan that discards 99% of what it read is the signal that an index would help.

Reading any plan: start at the deepest node, not the top — that is where execution starts and where the rows come from. Then look for three things: a node whose actual rows are wildly different from its estimate (bad statistics, and every choice above it is now suspect), a Rows Removed by Filter far larger than the rows kept (a missing index), and a loops count in the thousands (a correlated subquery that should be a join).
2 nodes · 22 pages

When to use — and when not

Use it when
  • Reading any plan.
  • Deciding which index or rewrite will change a plan.
Avoid it when
  • Trying to force a plan with hints — PostgreSQL has none, and the fix is almost always statistics or an index.

Failure modes

  • Nested loop with a sequential scan inside, looped thousands of times.
  • Hash join spilling because work_mem is too small for the build side.
  • Row estimate off by 100× from stale statistics, cascading into a bad join order.

See how this works internally →

Descend one layer: the same topic explained from the machinery up.