postgres://internals

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.

sql · live postgresrun me first — sets up the data
⌘/ctrl + enter
explain · live plan treethe bottleneck is highlighted

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:

// challengeuses the events table from the setup cell above

Make the lower(user_email) lookup use an Index Scan instead of a Seq Scan. Create the right index, then prove it with EXPLAIN.

⌘/ctrl + enter

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 Scan with 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/LIMIT helps.
  • 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 Sort or Hash that exceeds work_mem writes to disk; an index providing order, or more work_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.

// challengeuses the events table from the setup cell above

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

⌘/ctrl + enter

// 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

nextPartitioning