40. EXPLAIN ANALYZE: reading query plans
From "recognize the algorithm name" to actually reading a plan
If you've ever run EXPLAIN on a join, you may have spotted names like
Nested Loop/Hash Join/Merge Join in the output without a full
explanation of everything else on the page. This lesson is that full explanation — the
foundational skill for every remaining lesson in this module and Module
12.
EXPLAIN vs. EXPLAIN ANALYZE — a critical distinction
EXPLAIN SELECT * FROM film WHERE rental_rate > 4.00;
Plain EXPLAIN shows the planner's estimated plan — it does not
run the query. Costs and row counts are predictions, based on table
statistics (gathered by ANALYZE — the performance-killers lesson at
the end of this module shows what happens when they go stale).
EXPLAIN ANALYZE SELECT * FROM film WHERE rental_rate > 4.00;
EXPLAIN ANALYZE actually executes the query and shows real,
measured timing and row counts alongside the original estimates. This
distinction matters practically: EXPLAIN ANALYZE on an UPDATE,
DELETE, or a SELECT with heavy side effects (a function with side
effects, rare but possible) really performs that write — wrap it in
BEGIN ... ROLLBACK if you want to inspect a modifying query's plan
without committing the change:
BEGIN;
EXPLAIN ANALYZE UPDATE film SET rental_rate = rental_rate * 1.1 WHERE film_id = 1;
ROLLBACK;
Reading one plan line by line
Seq Scan on film (cost=0.00..66.50 rows=423 width=384)
Filter: (rental_rate > 4.00)
Seq Scan on film— the operation: a sequential scan (read every row in the table, in physical storage order) onfilm. Other common operations:Index Scan,Index Only Scan,Bitmap Heap Scan(next lesson covers what triggers each).cost=0.00..66.50— the planner's estimated cost, in arbitrary units (not milliseconds — a relative measure calibrated againstseq_page_costand other planner constants). The first number is the estimated cost to return the first row; the second is the estimated cost to return all rows.rows=423— the planner's estimated number of rows this step will produce.width=384— the planner's estimated average row width, in bytes.Filter: (rental_rate > 4.00)— the condition applied at this step. AFilter(as opposed to anIndex Cond, next lesson) means the row was fetched first, then checked against the condition afterward — every row in the table gets read, whether or not it matches.
The same plan, with EXPLAIN ANALYZE
Seq Scan on film (cost=0.00..66.50 rows=423 width=384) (actual time=0.015..0.412 rows=417 loops=1)
Filter: (rental_rate > 4.00)
Rows Removed by Filter: 583
New fields, all measured, not estimated:
actual time=0.015..0.412— actual milliseconds to first row, and to complete the step (cumulative acrossloops, see below).actual rows=417— the real row count, comparable directly against the estimate (rows=423) — a big divergence between estimated and actual rows is one of the most important things to notice in any plan, since it means the planner's statistics are stale or the query has a shape the planner can't estimate well, and is a leading cause of the planner choosing a bad plan elsewhere in a larger query (the performance-killers lesson returns to this).loops=1— how many times this step executed. A step nested inside aNested Loopexecutes once per outer row, soloopscan be far greater than 1, andactual timethere is the per-loop average, not the total — a detail that trips people up reading nested-loop plans until it's pointed out once.Rows Removed by Filter: 583— rows read and then discarded by the filter — a strong, direct signal that an index onrental_ratecould let the engine skip reading those 583 rows entirely (next lesson).
Top-to-bottom, but read cost bottom-up
A multi-line plan is a tree, indentation shows nesting, and Postgres prints it with the outermost operation first, its children indented below. But when reasoning about where time actually goes, read bottom-up: the innermost (most indented) operations run first and feed their output to the operations above them.
EXPLAIN ANALYZE
SELECT c.first_name, r.rental_date
FROM customer c
JOIN rental r ON r.customer_id = c.customer_id
WHERE c.customer_id = 1;
Nested Loop (cost=0.28..359.16 rows=32 width=14) (actual time=0.02..0.15 rows=32 loops=1)
-> Index Scan using customer_pkey on customer c (... actual time=0.01..0.01 rows=1 loops=1)
Index Cond: (customer_id = 1)
-> Seq Scan on rental r (... actual time=0.01..0.13 rows=32 loops=1)
Filter: (customer_id = 1)
Read this as: first, the indented Index Scan finds customer 1 (fast,
one row). Then, for each row that step produced (here just 1), the
Seq Scan on rental runs — reading the whole rental table, filtering
to customer_id = 1. The outer Nested Loop line's total cost/time is
the combination of both children. This bottom-up reading habit is the
single most useful skill for spotting which specific step is
responsible for a slow query's total time — the performance-tuning
capstone walks this workflow end to end.
Check yourself
- What's the practical difference between
EXPLAINandEXPLAIN ANALYZE— specifically, does either one actually execute the query? - In an
EXPLAIN ANALYZEplan, what does a large gap between the estimatedrows=and the actualrows=usually indicate? - Why should you read a multi-line plan's cost attribution bottom-up rather than top-down?