PostgreSQLX · Query Planning, Indexes and Performance MethodPlanner
Reading an EXPLAIN plan
What you'll learn
- Read a plan tree in execution order rather than in printed order
- Interpret loops correctly and compute the true cost of a nested node
- Identify the node responsible for a plan being slow
- Use the EXPLAIN options that answer specific questions
Prerequisites
Practice
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
EXPLAIN prints a tree with the root at the top. Execution goes the
other way: leaves first, each node feeding its parent. Reading it in
printed order is the most common reason people look at a plan and take
nothing from it.
The shape
Aggregate (cost=56.57..56.58 rows=1 width=8) (actual time=0.146..0.146 rows=1.00 loops=1)
Buffers: shared hit=3 read=3
-> Index Only Scan using ordersx_customer_idx on ordersx
(cost=0.43..51.67 rows=1962 width=0) (actual time=0.010..0.088 rows=2010.00 loops=1)
Heap Fetches: 0
Buffers: shared hit=3 read=3
The Index Only Scan runs first and hands its rows to the Aggregate.
Indentation is depth: a node’s children are the more-indented ->
entries directly beneath it.
Read from the deepest, last-listed node upward. That is execution order.
Every number on a node line
(cost=0.43..51.67 rows=1962 width=0) (actual time=0.010..0.088 rows=2010.00 loops=1)
| Field | Meaning |
|---|---|
cost=0.43..51.67 | Estimated startup cost .. estimated total cost |
rows=1962 | Estimated rows this node will produce |
width=0 | Estimated average row width in bytes |
actual time=0.010..0.088 | Real milliseconds to first row .. to last row |
rows=2010.00 | Actual rows produced, per loop |
loops=1 | How many times this node was executed |
The pairing to internalise is rows=1962 against rows=2010.00. That
is the estimate against reality, and lesson X-04 is entirely about it.
The options worth using
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS, WAL, FORMAT TEXT) SELECT …;
| Option | Gives you |
|---|---|
ANALYZE | Actually runs it, adds actual rows and timing |
BUFFERS | Pages hit, read, dirtied and written per node |
VERBOSE | Output column lists, schema-qualified names |
SETTINGS | Any planner setting that differs from its default |
WAL | WAL generated, for INSERT/UPDATE/DELETE |
COSTS OFF | Suppresses estimates — useful for stable test output |
TIMING OFF | Suppresses per-node timing, reducing measurement overhead |
FORMAT JSON | Machine-readable, for tooling |
BUFFERS should be automatic. Timing varies with cache state and
system load; buffer counts do not. A query reading 16,667 buffers is
doing more work than one reading 6, on any hardware, at any time of day.
SETTINGS is the one nobody uses and should. It reveals that the
session you are debugging has enable_seqscan = off or a modified
work_mem — which explains a plan you could not otherwise account for.
Node types you will meet
Scans. Seq Scan, Index Scan, Index Only Scan, Bitmap Heap Scan, Bitmap Index Scan, CTE Scan, Function Scan, Values Scan.
Joins. Nested Loop, Hash Join (with a Hash child that builds
the table), Merge Join (with Sort children unless input is already
ordered).
Aggregation. Aggregate (one result), GroupAggregate (needs
sorted input), HashAggregate (builds a hash table, no sort needed).
Ordering and limiting. Sort with Sort Method and Memory or
Disk — a Sort Method: external merge Disk: 84320kB line is a
work_mem problem, covered in Part XI. Limit, Incremental Sort.
Parallelism. Gather and Gather Merge collect from workers.
Anything below them named Parallel … runs in each worker.
Other. Materialize caches a subtree’s output for reuse.
Memoize caches lookups on the inner side of a nested loop — a node
whose Hits and Misses counts tell you whether it was worth it.
Subquery Scan, Append and Merge Append for partitions and unions.
Finding the expensive node
A procedure that works:
- Read
Buffersat the top. That is the total work. If it is small, the query is not doing much I/O and the problem is elsewhere. - Find the node with the largest
Buffers. Buffer counts are cumulative up the tree, so the node whose count is much larger than its parent’s other children is where the work happened. - Check its
loops. A modest node executed thousands of times is the commonest cause. - Compare its estimate to its actual. If they diverge, the problem is upstream — statistics, not execution.
- Only then look at timing. Time is a consequence; buffers and row counts are causes.
What to take from this
- Read bottom up and inside out. Indentation is depth.
loops > 1meansactual rowsandactual timeare per iteration. Multiply.Buffersis cumulative and unaffected by clock overhead. It is the most trustworthy number in the plan.- Always pass
BUFFERS. ConsiderSETTINGSwhen a plan makes no sense. - On parallel plans,
loops=3is the leader plus two workers, andWorkers Launchedmay be lower thanWorkers Planned. TIMING OFFremoves measurement overhead while keeping row counts.
Cross-course references
- Observability for Production Sysadmins — Part CII (Slow queries) covers getting from a latency series to the individual statement whose plan you then read, which is the step before this one.
- Linux for Production Sysadmins — Part LXXIX (Troubleshooting methodology) covers reading evidence bottom-up, which is exactly how a plan tree is read.
Quiz
Knowledge check · 6 questions
Q1. A plan node reads 'Bitmap Heap Scan (actual time=0.061..0.276 rows=30.00 loops=1000)' with 'Buffers: shared hit=6082 read=26918'. How much work did this node do?
Q2. A colleague cannot reproduce a plan you saw in a support session, on the same data and the same server. Which EXPLAIN option is most likely to explain the difference?
Q3. A parallel plan shows a Parallel Seq Scan with 'actual rows=670.00 loops=3' beneath a Gather. How many rows did that scan actually produce?
Q4. Which statements about EXPLAIN output are correct? Select all that apply.
Q5. Execution Time reported by EXPLAIN ANALYZE can meaningfully exceed the query's real duration, because instrumenting every node costs two clock reads per row.
Q6. Describe the procedure you would follow to find the expensive node in an unfamiliar plan.
Passing score: 75%. Answers are checked in this browser.