Read a Postgres Execution Plan and Fix the Slow Node
Run EXPLAIN ANALYZE, walk the plan tree node by node, compare estimated versus actual rows, and act on the node where the cost concentrates.
Task
Read an EXPLAIN ANALYZE plan, locate the node with the largest actual time and row misestimate, and fix the query or index accordingly.
When to use this
A dashboard query takes 8 seconds. Someone added an index and nothing changed, because nobody read the plan to see which node dominates.
Prerequisites
- PostgreSQL 13+ with a database you can query without impacting production
- The slow SQL statement
- Permission to create indexes in a test schema
Steps
1. Get the real plan with timings and row counts.
EXPLAIN (ANALYZE, BUFFERS) SELECT o.id, c.name FROM orders o JOIN customers c ON c.id = o.customer_id WHERE o.created_at > now() - interval '30 days';Expected output: The plan prints each node with actual time, actual rows, loops, and buffer usage, instead of guesses.
2. Find the node where the time concentrates.
EXPLAIN (ANALYZE) SELECT ...; -- read the "actual time" column: the first number of the top node minus its childrenExpected output: A nested loop node showing actual time=7800 with its inner side executing 12000 loops is the hotspot to attack.
3. Compare estimated rows against actual rows per node.
EXPLAIN (ANALYZE) SELECT ...;Expected output: A filter node showing rows=12000 estimated but rows=850000 actual exposes the statistics gap causing the bad join strategy.
4. Refresh statistics or add the index the plan is begging for.
ANALYZE orders; -- or: CREATE INDEX CONCURRENTLY idx_orders_created ON orders (created_at);Expected output: Re-planning after the fix changes the join order or scan strategy, and the hotspot node disappears or shrinks dramatically.
5. Re-run the same EXPLAIN and record the improvement.
EXPLAIN (ANALYZE, BUFFERS) SELECT ...; -- compare "Execution Time" with the baselineExpected output: Execution Time drops from 8000 ms to a double-digit number, and the diff between plans is the evidence for the pull request.
Troubleshooting
Problem
Estimated rows are wildly off after fresh ANALYZE
Solution
Raise the statistics target on the skewed column with ALTER TABLE ... ALTER COLUMN ... SET STATISTICS 500, then ANALYZE again.
Problem
Plan changes on production but not locally
Solution
Different data volume and settings make local plans diverge; capture the production plan with auto_explain and reason about that artifact.
Problem
EXPLAIN ANALYZE itself is too slow or has side effects
Solution
Wrap it in a transaction with ROLLBACK for writes, or fall back to plain EXPLAIN plus auto_explain captures from real traffic.
Limitations
- EXPLAIN ANALYZE executes the query, so never run it on destructive statements outside a rolled-back transaction.
- Plans depend on data distribution; a plan validated today can change after growth or major upgrades.
The plan already contains the verdict
When a query is slow, Postgres can print exactly what it did: every scan, join, and sort with actual timings, actual row counts, and buffer hits. Reading that tree beats guessing indexes every time, because the node with the largest actual time and the widest estimate gap tells you both what to fix and why the optimizer chose badly. One careful read usually replaces an afternoon of index roulette.
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.created_at > now() - interval '30 days';
-- Key lines to compare:
-- rows=12000 estimated ... rows=850000 actual (statistics gap)
-- actual time=7800.12..7800.13 loops=1 (hotspot node)
-- Execution Time: 8003.5 ms
Estimates drive strategy
The planner picks join algorithms from estimated cardinalities. When a node estimates twelve thousand rows and actually processes eight hundred fifty thousand, the chosen nested loop was reasonable for the estimate and disastrous for reality. Fixing statistics with ANALYZE or a higher statistics target often changes the strategy with zero code changes.
Multiply time by loops
The actual time column is per loop, not per query. A node showing five milliseconds with twelve thousand loops costs a full minute of the query. This is the single most common misreading of plans, and correcting it frequently relocates the hotspot from the top node to a small-looking inner scan.