Debugging Slow Queries
// the problem
Everything you've learned so far — pages, indexes, MVCC, the planner — converges on one practical skill: making a slow query fast. Great Postgres engineers don't guess; they follow a loop. Find the slow query, read its plan, fix the cause, verify it worked. Let's run that loop on real queries, in real Postgres, right here.
Step 1 — Find it
You can't fix what you can't see. The standard tool is the
pg_stat_statements extension, which aggregates every query's total
time, call count, and mean latency — so you can rank queries by total impact
(a fast query run a million times often beats a slow one run twice). In
production that's your starting point: ORDER BY total_exec_time DESC.
// note · the tool you'll live in
pg_stat_statements isn't bundled into this in-browser Postgres, but it's
the first thing to enable on any real server. Also watch pg_stat_activity for
queries running right now, and log slow ones with log_min_duration_statement.
Step 2 — Read the plan
Once you know which query, EXPLAIN (ANALYZE, BUFFERS) tells you why. It runs
the query and shows the plan tree with estimated vs actual rows, per-node
time, and buffer reads. Run it below — the tree highlights the node that spent
the most time on itself (the bottleneck), and flags any node whose row estimate
was wildly off.
That's a Seq Scan reading all 80,000 rows to find a handful — the
telltale sign of a missing (or unusable) index. The −N filtered on that node is
Rows Removed by Filter — all the rows Postgres read and then threw away.
Step 3 — Fix the cause
The filter is lower(user_email) = …. A normal index on user_email can't
help, because the query indexes the result of a function, not the column. The
fix is an expression index on exactly that expression:
Make the lower(user_email) lookup use an Index Scan instead of a Seq Scan. Create the right index, then prove it with EXPLAIN.
Re-run the visualizer above (after creating the index) and the Seq Scan becomes an Index Scan — milliseconds instead of a full table read. That's the loop: the plan named the problem, and the fix targeted the cause, not the symptom.
The usual suspects
Most slow queries are one of a handful of patterns — and the plan tells you which:
- Missing / unusable index — a
Seq Scanwith a selective filter, or an index defeated by a function or type mismatch on the column (use an expression index, or don't wrap the column). - Bad row estimates — a big gap between estimated and actual rows (flagged in
red above) means stale statistics → the planner costs everything wrong. Fix
with
ANALYZE(or more detailed / extended statistics). - Reading too much — fetching columns or rows you don't need; a covering
index or a tighter
WHERE/LIMIThelps. - N+1 queries — one query per row in a loop, from the app side. One join beats a thousand round-trips.
- Unnecessary sorts / hashes spilling to disk — a
SortorHashthat exceedswork_memwrites to disk; an index providing order, or morework_mem, avoids it.
// why it matters · indexes have a cost, too
The fix is often “add an index,” but every index slows writes and
uses space (from the B-tree lesson). Index the columns your slow, frequent
queries filter and join on — not everything. Partial indexes
(WHERE active) and covering indexes (INCLUDE) target the cost precisely.
Step 4 — Verify
Always re-EXPLAIN (ANALYZE) after a change and confirm the plan and the actual
time improved — not just that you added an index the planner ignored. Measuring
before and after is the whole discipline; the plan is your evidence.
Speed up counting a single day's events. Add an index on the date column and prove the planner now uses an index (not a full Seq Scan).
// what you now understand
- 01Debugging is a loop: find the query (pg_stat_statements by total time), read its plan (EXPLAIN ANALYZE), fix the cause, verify.
- 02EXPLAIN (ANALYZE, BUFFERS) shows estimated vs actual rows, per-node time, and buffers — the bottleneck is the node with the most self-time.
- 03A Seq Scan with a selective filter, or an index defeated by a function on the column, means a missing/unusable index — an expression index fixes the latter.
- 04A large estimate-vs-actual gap means stale statistics; ANALYZE fixes the planner's inputs.
- 05Other classic causes: reading too much data, app-side N+1 queries, and sorts/hashes spilling past work_mem.
- 06Indexes cost write speed and space — index the columns your frequent slow queries need, and use partial/covering indexes to target the cost.
- 07Always verify with a fresh EXPLAIN ANALYZE: confirm both the plan and the real time improved.
// self-test
EXPLAIN ANALYZE shows a Seq Scan with `Rows Removed by Filter: 999,900` on a query filtering `WHERE lower(email) = $1`, and a normal index on email exists but isn't used. Why?
// self-test
A query's plan shows a node estimated to return 5 rows but it actually returned 500,000. What's the most likely root cause?
// go deeper
- pg_stat_statements — find the queries that cost the most, in aggregate
- Using EXPLAIN — reading plans, ANALYZE, BUFFERS, estimate vs actual
- Index types & when to use them — expression, partial, covering, and non-B-tree indexes
- Populating statistics (ANALYZE) — keeping the planner's inputs fresh