Reading query plans with EXPLAIN

SQL · Advanced · 15 min

Guessing at slow queries wastes days; the plan tells you what is actually happening.

EXPLAIN shows the plan the optimiser chose: a tree of nodes, each producing rows for its parent. EXPLAIN ANALYZE actually runs the query and adds real timings and row counts. Because it executes the statement, wrap data-modifying queries in BEGIN; EXPLAIN ANALYZE ...; ROLLBACK;.

Each node line looks like:

Index Scan using orders_customer_id_idx on orders
  (cost=0.43..8.45 rows=3 width=48) (actual time=0.020..0.024 rows=2 loops=1)
  • cost=startup..total is in arbitrary planner units (roughly, sequential page reads). Use it to compare plans, not as milliseconds.
  • rows is the estimate; actual ... rows is reality. actual time is in milliseconds, per loop: multiply by loops.
  • Child nodes are indented below their parent; execution data flows upward.

Common nodes: Seq Scan (read the whole table), Index Scan, Index Only Scan (answered from the index alone), Bitmap Heap Scan (collect many matches from an index, then read pages in order), Nested Loop, Hash Join, Merge Join, Sort, HashAggregate.

How to read a slow plan:

1. Find the nodes where actual time jumps. 2. Compare estimated and actual rows. An estimate off by 100x usually means the planner chose a bad join method or order; the cause is often stale or insufficient statistics. 3. Look for Seq Scan on large tables behind a selective filter (missing index), Rows Removed by Filter in the millions, or a Sort spilling to disk (Sort Method: external merge).

Add BUFFERS (EXPLAIN (ANALYZE, BUFFERS)) to see how many pages came from cache versus disk. Tools like explain.dalibo.com visualise large plans.

Example

EXPLAIN ANALYZE
SELECT c.name, SUM(o.total)
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.ordered_at >= '2026-03-01'
GROUP BY c.name;
-- HashAggregate  (cost=245.1..247.6 rows=200 width=64)
--                (actual time=4.91..4.95 rows=180 loops=1)
--   Group Key: c.name
--   ->  Hash Join  (cost=38.5..236.3 rows=1760 width=38)
--                  (actual time=0.61..4.12 rows=1802 loops=1)
--         Hash Cond: (o.customer_id = c.id)
--         ->  Seq Scan on orders o  (cost=0.0..193.0 rows=1760 width=14)
--                                   (actual time=0.01..3.02 rows=1802 loops=1)
--               Filter: (ordered_at >= '2026-03-01 00:00:00+00')
--               Rows Removed by Filter: 8198
--         ->  Hash  (actual time=0.55..0.55 rows=1000 loops=1)
--               ->  Seq Scan on customers c  (...)
-- Planning Time: 0.31 ms
-- Execution Time: 5.10 ms

-- Safely analyse a write
BEGIN;
EXPLAIN ANALYZE UPDATE orders SET status = 'shipped' WHERE id = 102;
ROLLBACK;

Remember

  • EXPLAIN shows the chosen plan; EXPLAIN ANALYZE runs it and reports reality
  • Costs are relative units; actual time is ms per loop
  • Big gaps between estimated and actual rows point to statistics problems
  • EXPLAIN ANALYZE executes writes; wrap them in a rolled-back transaction

Watch out for

  • Running EXPLAIN ANALYZE on a DELETE in production and actually deleting the rows.

Check yourself

  1. A node shows actual time=0.050..0.050 rows=1 loops=2000. Roughly how long did it take in total?
  2. Estimated rows=5 but actual rows=48000 on a scan feeding a Nested Loop. What is the most likely root issue?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.postgresql.org/docs/current/using-explain.html