Overview

Read the plan before you tune. The planner picks scans and join orders from statistics, and EXPLAIN shows what it chose, so the plan answers “why is this slow?“. Run EXPLAIN ANALYZE with BUFFERS, read the plan top-down, and compare estimated to actual rows; a gap of roughly an order of magnitude is the usual sign of a wrong plan. Use pg_stat_statements to find what to tune. The umbrella rules live in postgres; a node-by-node reference is in postgres-explain.

Run EXPLAIN ANALYZE and read BUFFERS

EXPLAIN shows the estimate without running the query. EXPLAIN ANALYZE runs it and adds actual rows, time, and loops. Since Postgres 18, ANALYZE reports BUFFERS (cache hits and reads) automatically; name it anyway so the command behaves the same on older servers.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total_cents FROM orders
WHERE user_id = $1 AND created_at > now() - interval '30 days'
ORDER BY created_at DESC LIMIT 50;
  • ANALYZE executes the statement. For INSERT, UPDATE, or DELETE, wrap it in BEGIN; ... ROLLBACK;.
  • Add WAL for WAL records per node, and SERIALIZE to include the cost of converting output rows (including TOAST fetches). SERIALIZE explains a query whose plan is fast but whose client is slow on wide rows.
  • For a parameterized query ($1) with no values at hand, EXPLAIN (GENERIC_PLAN) shows the plan a prepared statement would reuse. It cannot combine with ANALYZE.

Read the plan top-down and compute totals

The root is what the client receives; the leaves are scans. Rows flow upward.

  • actual time and actual rows are per-loop averages. Multiply by loops for totals.
  • Buffers: shared hit=N read=M separates cache hits from reads. A read can still be served by the OS page cache.
  • Start at the node with the largest total actual time, fix it, and re-run.
  • cost is in planner units, not seconds. Compare costs as ratios between nodes. Low cost with high actual time usually means a bad row estimate.

Compare estimated rows to actual rows

A gap of roughly an order of magnitude between rows= (estimate) and actual rows= is the usual sign of a wrong plan.

Index Scan using orders_user_id_idx on orders
  (cost=0.43..2.50 rows=1 width=20) (actual time=0.04..18.23 rows=4200 loops=1)

This node estimated one row and returned 4,200, so a parent nested loop chosen on that estimate is probably the wrong join. Run ANALYZE orders, or raise ALTER TABLE ... ALTER COLUMN ... SET STATISTICS on skewed columns; see postgres-vacuum for the analyze schedule.

Match each scan type to its use

  • Sequential scan: right for small tables or queries returning most rows; wrong for selective predicates on large tables.
  • Index scan: right for selective predicates; wrong when the index returns most rows, because random heap reads cost more than a sequential scan.
  • Index-only scan: skips the heap for pages the visibility map marks all-visible, so watch Heap Fetches and keep the table vacuumed. See postgres-indexes for INCLUDE.
  • Bitmap heap scan: builds a page bitmap from one or more indexes, then visits each page once. It wins for moderately selective predicates on scattered rows.

Separate cold-cache problems from plan problems

A query that is fast warm and slow cold has an I/O problem. Run it twice. If read drops to near zero the second time, the first run was a cold start. If read stays high, the working set exceeds shared_buffers and the OS cache: add a covering index that shrinks the working set, or partition cold history (postgres-partitioning).

Feed pg_stat_statements into the loop

pg_stat_statements records normalized text, calls, and timing per query. Add it to shared_preload_libraries, restart, then CREATE EXTENSION pg_stat_statements;.

SELECT calls, mean_exec_time, total_exec_time, query
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 20;

Sort by total_exec_time; mean time hides queries that are cheap but run constantly. Attribute the pain back to routes with observability and the slow-query log.