Reading EXPLAIN and tuning a slow query
What cost, rows and loops mean, how to spot a bad estimate, extended statistics, and rewriting predicates so an index can be used.
After this lesson you can
- Read an EXPLAIN ANALYZE plan inside out, including loops
- Compare estimated and actual rows and say why a large gap matters
- Fix a correlated-column misestimate with CREATE STATISTICS
- Rewrite a non-sargable predicate into an index-friendly one
EXPLAIN query shows the plan the planner chose, without running it.
EXPLAIN ANALYZE query runs the query and adds what actually
happened. Because it executes, EXPLAIN ANALYZE on an UPDATE or
DELETE really changes data; wrap it in BEGIN ... ROLLBACK.
Anatomy of a plan
A plan is a tree. Each node pulls rows from its children (the indented
nodes under its ->), so you read it inside out: the most indented
nodes run first, and the top node returns the result.
CREATE TABLE customers (id int PRIMARY KEY, city text NOT NULL, country text NOT NULL, signed_up timestamp NOT NULL);INSERT INTO customersSELECT g, (ARRAY['Tbilisi','Batumi','Kutaisi','Berlin','Munich','Hamburg','Warsaw','Krakow','Gdansk','Paris'])[1 + g % 10], (ARRAY['GE','GE','GE','DE','DE','DE','PL','PL','PL','FR'])[1 + g % 10], timestamp '2022-01-01' + g * interval '1 hour'FROM generate_series(1, 20000) AS g;ANALYZE customers;EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)SELECT c.id, d.cityFROM customers cJOIN customers d ON d.id = c.id + 1WHERE c.city = 'Paris' AND c.id < 200;On each node:
cost=0.58..167.42— the planner's estimate in arbitrary units: the cost before the first row can be returned, then the total. Only useful for comparing plans, not as milliseconds.rows=20— estimated rows per execution of the node;widthis the estimated average row size in bytes.actual rows=1 loops=20— the node ran 20 times and returned 1 row on average per loop. Total rows = rows x loops. ANested Loopruns its inner side once per outer row, which is where loops come from.Rows Removed by Filter— rows the node read and threw away; a big number here on a large table is often the first clue.
TIMING OFF and SUMMARY OFF keep the output free of timings so it is
the same on every run; on a real server you want timings on, and
EXPLAIN (ANALYZE, BUFFERS) to see how many pages were found in
PostgreSQL's shared buffers (hit) and how many had to be read in from
outside them (read, which may still come from the operating system's
cache).
Estimates drive everything
The planner chooses join order, join type and access path from its row
estimates, which come from statistics gathered by ANALYZE (autovacuum
runs it for you after enough changes). When a node's estimate is off by an
order of magnitude, every decision above it is built on that error: a
nested loop chosen for "20 rows" that really gets 200,000 is a classic
slow query.
CREATE FUNCTION plan_of(q text, opts text DEFAULT 'COSTS OFF') RETURNS text LANGUAGE plpgsql SET max_parallel_workers_per_gather = 0 AS $$DECLARE line text; result text := '';BEGIN FOR line IN EXECUTE 'EXPLAIN (' || opts || ') ' || q LOOP result := result || CASE WHEN result = '' THEN '' ELSE ' | ' END || trim(line); END LOOP; RETURN result;END $$;CREATE TABLE customers (id int PRIMARY KEY, city text NOT NULL, country text NOT NULL, signed_up timestamp NOT NULL);INSERT INTO customersSELECT g, (ARRAY['Tbilisi','Batumi','Kutaisi','Berlin','Munich','Hamburg','Warsaw','Krakow','Gdansk','Paris'])[1 + g % 10], (ARRAY['GE','GE','GE','DE','DE','DE','PL','PL','PL','FR'])[1 + g % 10], timestamp '2022-01-01' + g * interval '1 hour'FROM generate_series(1, 20000) AS g;ANALYZE customers;CREATE TEMP TABLE before ASSELECT plan_of($$SELECT * FROM customers WHERE city = 'Tbilisi' AND country = 'GE'$$, 'ANALYZE, TIMING OFF, SUMMARY OFF') AS plan_before; CREATE STATISTICS customers_city_country (dependencies) ON city, country FROM customers;ANALYZE customers; SELECT plan_before, plan_of($$SELECT * FROM customers WHERE city = 'Tbilisi' AND country = 'GE'$$, 'ANALYZE, TIMING OFF, SUMMARY OFF') AS plan_afterFROM before;Sargable predicates
A predicate is sargable (search-argument-able) when the indexed column stands alone on one side of the comparison. Wrap the column in a cast, function or arithmetic and the index on the bare column cannot be used; move the work to the constant side instead.
CREATE FUNCTION plan_of(q text, opts text DEFAULT 'COSTS OFF') RETURNS text LANGUAGE plpgsql SET max_parallel_workers_per_gather = 0 AS $$DECLARE line text; result text := '';BEGIN FOR line IN EXECUTE 'EXPLAIN (' || opts || ') ' || q LOOP result := result || CASE WHEN result = '' THEN '' ELSE ' | ' END || trim(line); END LOOP; RETURN result;END $$;CREATE TABLE customers (id int PRIMARY KEY, city text NOT NULL, country text NOT NULL, signed_up timestamp NOT NULL);INSERT INTO customersSELECT g, (ARRAY['Tbilisi','Batumi','Kutaisi','Berlin','Munich','Hamburg','Warsaw','Krakow','Gdansk','Paris'])[1 + g % 10], (ARRAY['GE','GE','GE','DE','DE','DE','PL','PL','PL','FR'])[1 + g % 10], timestamp '2022-01-01' + g * interval '1 hour'FROM generate_series(1, 20000) AS g;CREATE INDEX customers_signed_up ON customers (signed_up);ANALYZE customers;SELECT label, plan_of(q) AS planFROM (VALUES (1, 'cast the column', $$SELECT id FROM customers WHERE signed_up::date = '2023-03-05'$$), (2, 'half-open range', $$SELECT id FROM customers WHERE signed_up >= '2023-03-05' AND signed_up < '2023-03-06'$$), (3, 'function on column', $$SELECT id FROM customers WHERE date_trunc('day', signed_up) = '2023-03-05'$$), (4, 'arithmetic on column', $$SELECT id FROM customers WHERE signed_up + interval '1 day' > '2024-04-01'$$), (5, 'arithmetic moved', $$SELECT id FROM customers WHERE signed_up > timestamp '2024-04-01' - interval '1 day'$$)) AS t(n, label, q)ORDER BY n;A tuning checklist
- Run
EXPLAIN (ANALYZE, BUFFERS)on the real query with real parameters. - Find the node where most time goes, and the lowest node where estimated and actual rows diverge.
- Fix the cause: refresh or extend statistics, make the predicate
sargable, add or reshape an index, or rewrite the query (for example
NOT INtoNOT EXISTS). - Re-run and compare. A plain
EXPLAINshows guesses; tune from actuals.
Common mistakes
- Comparing
actual rowswithrowswithout noticingloops, or multiplying the estimate by loops: both numbers are already per loop. - Treating cost units as milliseconds.
WHERE created_at::date = ...on an indexed timestamp, which cannot use the index on the bare column, as the example shows.- Running
EXPLAIN ANALYZE DELETE ...on production "just to look".
What interviewers ask
- EXPLAIN versus EXPLAIN ANALYZE? Estimated plan versus executed plan with actual rows and timings; the latter runs the statement.
- A query is slow; how do you investigate?
EXPLAIN (ANALYZE, BUFFERS), find estimate-versus-actual gaps and expensive nodes, check statistics, sargability and indexes. - What does loops=1000 on an inner node mean? It ran once per outer row; multiply its per-loop rows and time by 1000 to see its real cost.
Exercise · Pro
What the planner guesses for two correlated filters
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade