← Back to Postgres EXPLAIN Analyzer

How to read a Postgres EXPLAIN ANALYZE plan and fix a slow query

A plan is a tree. Postgres runs the leaves first and feeds rows upward, so read it from the most indented line outward and ask one question at each node: how much time was spent here, and why?

1. Get a real plan

EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

Plain EXPLAIN shows estimates only. ANALYZE executes the statement, so wrap writes in BEGIN ... ROLLBACK. BUFFERS adds I/O per node.

2. Find self time, not the biggest number

A node's actual time includes its children, and it is a per-loop figure. Self time is time × loops minus the children's. A node that reads 0.15 ms but runs loops=4800 is 720 ms. The analyzer does this arithmetic for every node and lists the top three.

3. Rows Removed by Filter

A scan that reads 100,000 rows and keeps 5 is doing 99,995 rows of wasted work. On a Seq Scan the fix is usually an index on the filtered columns, equality columns first and range columns last. On an Index Scan it means the index covers only part of the predicate.

4. Estimates versus actuals

Compare rows= in the cost bracket with rows= in the actual bracket. A gap of 10× or more means the planner chose join order and method on bad information. Run ANALYZE, and use CREATE STATISTICS for correlated columns.

5. Spills

Sort Method: external merge Disk: 4096kB and Batches: 8 on a Hash node both mean the work did not fit in work_mem. Raise it for the one query with SET LOCAL work_mem, rather than globally.

6. Nested loops over sequential scans

A Nested Loop whose inner side is a Seq Scan with thousands of loops re-reads the table once per outer row. Index the join column or let the planner pick a hash join.

psql -At -c "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT ..." | node cli.mjs analyze -

Common questions

Why is my Postgres query using a sequential scan?

Either no usable index exists for the filter, the planner expects to read most of the table anyway, or its row estimate is wrong. A Seq Scan with a large 'Rows Removed by Filter' is the sign of a missing index; a Seq Scan that keeps most rows is usually correct.

What does a big gap between rows= estimates and actual rows mean?

The planner's statistics are stale or cannot model the predicate, usually correlated columns. Run ANALYZE, then consider CREATE STATISTICS on the columns used together. Wrong estimates are the root cause of most bad join choices.

How do I stop a sort from spilling to disk?

Raise work_mem for that query with SET LOCAL, or add an index that already returns rows in the ORDER BY order. Sort Method: external merge Disk means it spilled.