Reading Postgres EXPLAIN ANALYZE: A Practical Guide to Query Plans
How to read a Postgres query plan: EXPLAIN vs EXPLAIN ANALYZE, BUFFERS, costs vs actual times, loops, scan and join types, spotting bad row estimates, sorts spilling to disk — plus pg_stat_statements and auto_explain for finding the queries worth fixing.
When a query is slow, guessing at indexes wastes time. EXPLAIN ANALYZE shows you exactly what Postgres did: which tables it scanned and how, which join algorithms it chose, how many rows each step produced, and where the time went. Reading plans fluently is the single most valuable Postgres performance skill.
EXPLAIN vs EXPLAIN ANALYZE
EXPLAINshows the planned strategy and the planner's estimates. It doesn't run the query.EXPLAIN ANALYZEruns the query and adds actual row counts and timings.
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.total
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.country = 'DE' AND o.created_at > now() - interval '30 days';
BUFFERS adds how many 8 KB pages were read from memory (shared hit) or from disk (read) — usually the best indicator of real work. (In PostgreSQL 18, ANALYZE includes buffer information by default.)
Careful: EXPLAIN ANALYZE really executes the statement. For UPDATE, DELETE or INSERT, wrap it in a transaction you roll back:
BEGIN;
EXPLAIN ANALYZE DELETE FROM sessions WHERE expires_at < now();
ROLLBACK;
Anatomy of a plan
Hash Join (cost=12.50..845.20 rows=420 width=16) (actual time=0.31..9.84 rows=397 loops=1)
Hash Cond: (o.customer_id = c.id)
Buffers: shared hit=310
-> Index Scan using orders_created_at_idx on orders o (cost=0.43..812.00 rows=8400 width=24) (actual time=0.02..6.10 rows=8120 loops=1)
Index Cond: (created_at > (now() - '30 days'::interval))
-> Hash (cost=11.00..11.00 rows=120 width=8) (actual time=0.25..0.25 rows=118 loops=1)
-> Seq Scan on customers c (cost=0.00..11.00 rows=120 width=8) (actual time=0.01..0.20 rows=118 loops=1)
Filter: (country = 'DE'::text)
Rows Removed by Filter: 3882
Planning Time: 0.4 ms
Execution Time: 10.1 ms
- The tree runs inside-out. Indented child nodes feed their parent. Read from the most indented upwards.
cost=startup..total— the planner's estimate in arbitrary units. Useful for comparing alternatives, not as milliseconds.rows=in the first brackets is the estimate; in theactualbrackets, the real count.actual time=first..last— milliseconds to the first row and to the last row, per loop.loops— how many times the node ran. Multiply time and rows byloopsfor the true total — crucial inside nested loops.Rows Removed by Filter— rows read and thrown away. Large numbers suggest a missing or unsuitable index.
Scan types
| Node | Meaning |
|---|---|
| Seq Scan | Reads the whole table. Fine for small tables or when most rows are needed; a red flag on a big table returning few rows. |
| Index Scan | Uses an index to find rows, then fetches each from the table. |
| Index Only Scan | Answers entirely from the index (needs a covering index and an up-to-date visibility map — see VACUUM). Watch Heap Fetches. |
| Bitmap Index / Heap Scan | Collects matching row locations from one or more indexes, then reads table pages in order. Good for medium selectivity and combining indexes. |
Join types
| Node | Good when |
|---|---|
| Nested Loop | The outer side is small; the inner side has an index. Terrible when the outer side is unexpectedly large. |
| Hash Join | Joining larger sets on equality; builds a hash table of the smaller side. Watch for Batches > 1 (spilled to disk). |
| Merge Join | Both inputs already sorted on the join key. |
The most important thing to check: estimates vs actuals
Most bad plans come from bad row estimates. Compare estimated rows= with actual rows= node by node:
- Off by 10× or more — the planner chose a strategy for a situation that doesn't exist. Classic case: it expects 5 rows, picks a Nested Loop, gets 500,000 and runs the inner side 500,000 times.
Fixes:
ANALYZE tablename;— refresh statistics, especially after bulk loads.Raise the statistics target for skewed columns:
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;thenANALYZE.Extended statistics for correlated columns (e.g.
cityandcountry), which the planner otherwise assumes are independent:CREATE STATISTICS orders_city_country (dependencies) ON city, country FROM orders; ANALYZE orders;Rewrite the predicate so it can use statistics and indexes (avoid wrapping indexed columns in functions; see below).
Other things to look for
Sort Method: external merge Disk: 51200kB— the sort didn't fit inwork_memand spilled to disk. Add an index that provides the order, reduce the rows being sorted, or raisework_memfor that query.- Functions on indexed columns:
WHERE lower(email) = $1can't use a plain index onemail. Create an expression index onlower(email). (Database indexes.) LIMITwithORDER BYon an unindexed column — sorts everything to return ten rows.- High
Planning Time— huge numbers of partitions or complex queries; occasionally worth addressing. JITsections on short queries — JIT compilation can cost more than it saves for OLTP queries; consider raisingjit_above_costor disabling it.
Finding which queries to explain
Don't optimise at random. Find the queries costing the most total time:
pg_stat_statements(an extension) aggregates every query's calls, total and mean time:SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 1) AS mean_ms, left(query, 80) FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;A 5 ms query called 2 million times a day matters more than a 2-second report run once.
auto_explainlogs the plans of queries slower than a threshold automatically, capturing real production plans with real parameters.
Tools that help
Paste plans (text or FORMAT JSON) into an online plan visualiser to see the tree, the slowest nodes and the estimate mismatches highlighted. Useful for long plans.
The summary
EXPLAIN= plan and estimates;EXPLAIN (ANALYZE, BUFFERS)= actual execution. Roll back DML.- Read inside-out; multiply by
loops; compare estimated vs actual rows. - Bad estimates →
ANALYZE, statistics targets, extended statistics. - Watch for big Seq Scans, rows removed by filter, disk sorts, and functions on indexed columns.
- Use
pg_stat_statementsto choose which queries to fix.
EasySpawn gives Claude Code direct access to your app's PostgreSQL on the same server, so it can run EXPLAIN ANALYZE on real data, propose an index, and measure the difference before and after. See how it works or join the waitlist.
Related: The N+1 Query Problem · Postgres Full-Text Search · pgvector Tutorial · Why Is My Website Slow?
Keep reading
Postgres "Deadlock Detected": Why It Happens and How to Prevent It
ERROR: deadlock detected. How Postgres deadlocks happen, how to read the log detail, the common causes — inconsistent lock ordering, batch updates, foreign keys, upserts — and the fixes: consistent ordering, shorter transactions, explicit locking, and safe retries.
Postgres Read Replicas: Streaming Replication, Lag, and Read-Your-Writes
How Postgres physical streaming replication works, sync vs async, measuring replication lag, query conflicts and hot_standby_feedback, routing reads in your app without breaking read-your-writes, replication slots that fill disks, and when a replica is the wrong fix.