Query planning & executionFree lessonSource-grounded2 real lab transcripts

Reading EXPLAIN: How the Planner Describes a Query

A query plan is a tree of jobs.

The top asks its children for data, scan nodes fetch it, and rows move back toward the result. EXPLAIN shows the forecast; EXPLAIN ANALYZE executes it. Read from the leaves inward, while remembering that sorts, hashes, and bitmap nodes can build a batch before returning rows.

Read the execution

A plan is a tree in motion.

PostgreSQL pulls rows from the leaves toward the root. Follow that flow once, then use estimates, loops, and buffers to find where reality diverges.

01 / Follow the rows

Read from the leaves inward.

Each box is an executor node. Select a node or play the four-beat path to watch rows move toward the result.

Leaf 1

A leaf fetches candidate rows.

The sequential scan reads customers and emits the 80 rows in region EU. Leaves touch tables or indexes; they are where data enters the plan.

Remember: leaves fetch, parents combine, and the root returns. “Inside-out” is row flow, not indentation trivia.

02 / Find the lie

Compare forecast with reality.

Change one diagnostic signal. The plan shape stays still while the reason it hurts becomes obvious.

Index Scan on orders_customer_idcost=0.42..8.47 rows=2 width=16actual time=0.010..0.024 rows=12 loops=80Buffers: shared hit=640 read=0

The compounding error

Estimated total160Actual total960

A small lie repeats 80 times.

The planner expected 160 inner rows in total but received 960. That cardinality miss can make a once-cheap join strategy expensive.

Diagnostic order

rows estimate vs actual multiply by loops inspect BUFFERS fix the first broken assumption

03 / Prove it yourself

Put the real plan under the same lens.

The canonical lesson query is rendered once here. Read every node inside-out and compare its forecast with the captured run.

Lab-verified · correctedOpen the psql proof
Run in psql
-- Always analyze with buffers; read estimated vs actual rows on every node
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE customer_id = 42;

-- Force the contrast: a stale-stats table will mis-estimate
ANALYZE orders;            -- refresh stats, then re-run the EXPLAIN above
SET enable_seqscan = off;  -- session-only: see the alternative plan's cost
Real output PostgreSQL 17.10
QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on orders  (cost=4.59..126.41 rows=39 width=23) (actual time=0.073..0.221 rows=49 loops=1)
   Recheck Cond: (customer_id = 42)
   Heap Blocks: exact=47
   Buffers: shared hit=49
   ->  Bitmap Index Scan on idx_orders_customer  (cost=0.00..4.59 rows=39 width=0) (actual time=0.014..0.014 rows=49 loops=1)
         Index Cond: (customer_id = 42)
         Buffers: shared hit=2
 Planning:
   Buffers: shared hit=87
 Planning Time: 1.302 ms
 Execution Time: 0.263 ms
(11 rows)

Payoff: the estimate/actual comparison tells you whether statistics lied; loops reveals repeated work; BUFFERS separates shared-buffer hits from blocks requested into shared buffers.

Source trailUnder the hood
01
ExplainQuerycommands/explain.c

Builds the EXPLAIN operation and chooses whether the statement is executed.

02
ExecutorStartexecutor/execMain.c

Initializes the executor state and plan tree for ANALYZE.

03
ExecProcNodeFirstexecutor/execProcnode.c

Installs the instrumentation wrapper or real node method on a plan node's first call.

Source-level guardrails

  • EXPLAIN is a forecast; ANALYZE is a run. Only ANALYZE executes the statement and records actual timing, rows, and loops. ExplainQuery
  • Rows and time are per loop. Rows times loops gives total output; time times loops is inclusive, so never sum parent and child time. ExplainNode
  • Planner cost is not elapsed milliseconds. It is a relative model calibrated by cost constants; compare alternatives, not cost to wall-clock time. costsize.c

EXPLAIN formats the plan; the executor starts at the root and requests downward while tuples flow upward. Blocking, bitmap, and hash nodes are important exceptions to a literal one-tuple-at-a-time story.

What to remember

EXPLAIN prints the executor's node tree with cost=startup..total rows width; EXPLAIN (ANALYZE, BUFFERS) adds the truth, actual time, rows, loops, and cache vs disk reads. Read inside-out, multiply by loops, and chase the biggest estimate-vs-actual gap. Because PostgreSQL has no hints, the fix is almost always better statistics or a better index, not arguing with the planner.

Say it out loud

Close the page and explain this to someone who has not read it.

Can you read a plan as an executor tree, leaves first, estimates vs actuals per node, and name the node that lied, not just whether an index appeared? Index yes/no is junior. Estimate divergence + join method + loops is hire-ready.

Then compare with a full answer

Start with executor flow. The root requests work from children; scan leaves introduce data and result tuples usually move upward, while blocking and MultiExec nodes can materialize or return batches. EXPLAIN is the planner's forecast. EXPLAIN ANALYZE runs the statement and reports actual rows and time as per-loop averages. Rows times loops gives total node output, but timing is inclusive, so do not sum parent and child time. Compare cardinality first, then loops and BUFFERS: shared hit means already in PostgreSQL shared buffers; shared read means requested into them, not proven physical disk I/O. Fix the first broken assumption with statistics, query shape, or an index, then measure again.

It has to connect

  • Executor flow: the root requests work downward and tuples normally return upward through iterator nodes. Blocking and MultiExec nodes are the important materializing/batch exceptions.
  • EXPLAIN = planner estimates (cost, planned rows). EXPLAIN ANALYZE = run the query: actual time, actual rows, loops. Without ANALYZE you are reading a forecast.
  • Per-node diagnosis: compare estimated rows with per-loop actual rows, then multiply rows by loops for total output. A divergence is evidence of a broken cardinality assumption and can distort downstream choices.
  • BUFFERS separates shared-buffer hits from blocks requested into shared buffers; a shared read may still come from the operating-system cache.
  • Nested Loop is not always wrong: tiny outer + indexed inner can be right. Bad when outer is large or inner becomes a sequential scan per loop, prove it from actual rows × loops.
  • Fix path: stats (ANALYZE, extended stats), selectivity (predicates, correlation), then rewrite/index, not "add an index" as the only move.

Where it usually stops short

"EXPLAIN shows whether it uses an index." Collapses a diagnostic tree into a yes/no. No iterator model, no estimate vs actual, no loops, no next measurement. Follow-up ("why this Nested Loop?") dies.

Push further

  1. Do you read a plan tree top-down or inside-out, and what does each node actually do at runtime?
  2. EXPLAIN vs EXPLAIN ANALYZE: what numbers exist only after ANALYZE, and what does loops change about how you read time and rows?
  3. Nested Loop with millions of inner rows, when is that a smoking gun, and what do you check before rewriting the query?
Read the written reference7 sections · ~480 words · 1 runnable queryOne thing firstCost numbersEXPLAIN ANALYZE: estimates vs realityLoops trapBUFFERS tells the I/O storyCommon node typesTurning a plan into a fix

One thing first

A bad plan is almost never a dumb planner, it is a planner working from a wrong row estimate. So the one skill that matters when reading EXPLAIN is comparing the planner's estimated rows to the actual rows; the biggest mismatch is where your problem lives.

Cost numbers

Each node shows cost=startup..total rows=N width=B:

  • startup cost, work before the first row can be emitted (e.g. a sort must read everything first).
  • total cost, work to return all rows.
  • rows, the planner's estimate of rows emitted.
  • width, average row size in bytes.

Costs are in arbitrary units anchored to seq_page_cost = 1.0. They are only meaningful relative to each other.

EXPLAIN ANALYZE: estimates vs reality

Plain EXPLAIN shows estimates; EXPLAIN ANALYZE actually runs the query and adds actual time=...rows=...loops=.... The single most valuable habit is comparing estimated rows to actual rows. A large gap means the planner is working from bad statistics, and a bad row estimate is the root cause of most bad plans (wrong join order, wrong join type, wrong scan).

Lab-verified
SQL
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM orders WHERE customer_id = 42;
Real psql output, captured in the lab
QUERY PLAN                                                          
------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on orders  (cost=4.59..126.41 rows=39 width=23) (actual time=0.085..0.232 rows=49 loops=1)
   Recheck Cond: (customer_id = 42)
   Heap Blocks: exact=47
   Buffers: shared hit=47 read=2
   ->  Bitmap Index Scan on idx_orders_customer  (cost=0.00..4.59 rows=39 width=0) (actual time=0.029..0.030 rows=49 loops=1)
         Index Cond: (customer_id = 42)
         Buffers: shared read=2
 Planning:
   Buffers: shared hit=86 read=1
 Planning Time: 2.450 ms
 Execution Time: 0.274 ms
(11 rows)

Loops trap

In a nested loop, the inner node's actual rows and actual time are per loop. To get the true total, multiply by loops. A node that looks cheap (rows=1) executed 2 million times is your bottleneck. Always factor in loops before deciding what is slow.

BUFFERS tells the I/O story

With BUFFERS, each node reports shared hit (found in cache) and read (fetched from disk). High read on a node points to a working set that does not fit in shared_buffers or an index that is not being used. shared hit with high counts can still be slow if the node is visited in a huge loop.

Common node types

  • Seq Scan, read the whole table. Correct when returning a large fraction of rows.
  • Index Scan, walk an index, fetch matching heap tuples. Good for selective predicates.
  • Index Only Scan, return columns from the index; candidate heap pages without an all-visible bit still cause heap fetches inside the same node.
  • Bitmap Heap Scan, build a bitmap of matching TIDs from one or more indexes, then read the heap in physical order. Good for medium selectivity.
  • Nested Loop / Hash Join / Merge Join, the three join strategies.

Turning a plan into a fix

Reading a plan is only useful if it changes what you do next. The decision tree:

  • Find where row flow, loops, or node-local work first becomes expensive. Parent timing includes time spent pulling children, so do not add every node's time together.
  • Check whether its estimated rows match actual rows.
  • If estimates are wrong → fix statistics (ANALYZE, extended statistics, higher statistics_target).
  • If estimates are right but the plan is still poor → reconsider indexes or query shape.
  • BUFFERS is non-negotiable, shared hit was already in PostgreSQL shared buffers; shared read was requested into them and may still be served by the OS cache.
  • auto_explain captures plans for slow queries in production without manual reproduction.
  • enable_* toggles (seqscan, nestloop, hashjoin) are diagnostic levers, not permanent settings.
Check it against the source3 citations in postgres/postgres · file, symbol and line verified on REL_17_STABLE

Anchored to postgres/postgres on REL_17_STABLE and cross-checked against the manual for PostgreSQL 15–18. Every query here was run and its output captured on a throwaway PostgreSQL 17.10 lab; corrections are noted inline. §14.1 Using EXPLAIN in the official docs →

ShareLinkedInX

Finished a free lesson?

Pro opens the rest of the engine course

You felt one mechanism. Pro is the full bodies, interview depth, and the tracks that build on this session.

FollowSubstackLinkedInnew errors · lab notes · hiring loops