EXPLAIN & EXPLAIN ANALYZE
Reading execution plans
The Query Planner picks a strategy silently —
EXPLAIN is how you see which one it actually chose, and whether its
estimates matched reality.
EXPLAIN vs EXPLAIN ANALYZE
EXPLAIN alone shows the planned execution without running the query:
EXPLAIN SELECT * FROM accounts WHERE balance > 1000;
-- Seq Scan on accounts (cost=0.00..18.50 rows=333 width=12)
-- Filter: (balance > 1000)EXPLAIN ANALYZE actually executes the query and adds real timing and
row counts alongside the estimates:
EXPLAIN ANALYZE SELECT * FROM accounts WHERE balance > 1000;
-- Seq Scan on accounts (cost=0.00..18.50 rows=333 width=12)
-- (actual time=0.015..0.089 rows=310 loops=1)
-- Filter: (balance > 1000)
-- Rows Removed by Filter: 690
-- Planning Time: 0.112 ms
-- Execution Time: 0.121 msEXPLAIN ANALYZE really runs the query — including a real DELETE,
UPDATE, or INSERT. Wrap it in a transaction you roll back if you're
analyzing a write: BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;.
Add BUFFERS to see actual page I/O — how much came from shared buffers
(cache) versus disk, which is often more useful than timing alone for
diagnosing a slow query:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM accounts WHERE balance > 1000;
-- ...
-- Buffers: shared hit=1 read=17hit is a page already in shared memory; read came from disk (or the OS
page cache). A query that's all hit on a warm cache but still slow points
away from I/O and toward CPU-bound work — sorting, hashing, or function
evaluation.
Reading the plan tree
Plans nest: the innermost, most-indented lines execute first, feeding rows up to the operations above them. For a join:
EXPLAIN ANALYZE
SELECT a.id, o.total
FROM accounts a
JOIN orders o ON o.account_id = a.id
WHERE a.balance > 1000;
-- Hash Join (cost=25.00..65.30 rows=310 width=16)
-- (actual time=0.45..2.10 rows=298 loops=1)
-- Hash Cond: (o.account_id = a.id)
-- -> Seq Scan on orders o (cost=0.00..30.00 rows=2000 width=12)
-- (actual time=0.01..0.55 rows=2000 loops=1)
-- -> Hash (cost=18.50..18.50 rows=310 width=8)
-- (actual time=0.35..0.35 rows=310 loops=1)
-- -> Seq Scan on accounts a (cost=0.00..18.50 rows=310 width=8)
-- (actual time=0.01..0.20 rows=310 loops=1)
-- Filter: (balance > 1000)Combines the two branches below — builds a hash table from the smaller side (accounts), then probes it once per orders row.
Read this bottom-up: scan accounts filtering balance > 1000, build a
hash table from the result, scan orders in full, then probe the hash
table for each orders row to find matches.
The one signal that matters most
Every line has both an estimated row count (rows=310 in the cost
line) and an actual row count (rows=298 in the actual-time line).
When these diverge wildly — the planner expected 10 rows and got 100,000 —
every decision built on top of that estimate (which join strategy, which
scan type) is likely wrong too, even though each individual choice looked
reasonable given the (bad) numbers it had.
A large estimate/actual mismatch almost always traces back to stale or
insufficient statistics — see Query Planner.
Run ANALYZE on the table first before assuming the query itself needs
rewriting.
With planning and diagnosis covered, the next group of lessons moves back to SQL itself — starting with Joins, CTEs & Recursion.