← Recipes

阅读中文版本

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.

~30 mindatabasepostgresperformance

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 children

Expected 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 baseline

Expected 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.