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 return just 16 — the telltale sign of a missing or unusable index. The −N filtered on that node is Rows Removed by Filter: 79,984 — every row Postgres read and then immediately threw away, pure wasted work (~21 ms of it). With the right index this same lookup drops to a fraction of a millisecond — the ~700× difference you'll create in Step 3.

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 — a fraction of a millisecond instead of a full table read. That's the loop: the plan named the problem, and the fix targeted the cause.

Why the plain index didn't help: sargability

It's worth seeing exactly why an ordinary index on user_email is useless here. Add one and run both lookups: on the bare column it's used instantly; wrap that same column in lower(...) and the index is ignored — straight back to a full Seq Scan:

sql · live postgresthe same index — used, then defeated
⌘/ctrl + enter

An index stores values in the column's raw order; lower(user_email) is a different value the index knows nothing about, so the planner can't use it. A predicate an index can satisfy is called sargable (“Search ARGument-able”). The classic index-killers all make a predicate non-sargable by touching the indexed column:

  • wrapping the column in a functionlower(col), date(col), col + 1 (fix: index the expression, or move the work to the other side, e.g. col = upper($1));
  • a cast forced onto the column — comparing a text column to a number, or col::text = …;
  • a leading wildcardLIKE '%foo' can't use a B-tree, though a trailing one ('foo%') can.

Rule of thumb: keep the indexed column bare on one side of the comparison and most of these evaporate.

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 means a missing or unusable index (here: 80,000 rows read, 79,984 removed, ~21 ms → sub-millisecond Index Scan after the fix).
  • 04An index only helps a *sargable* predicate — the indexed column bare on one side. Wrapping it in a function (lower(col)), a cast on the column, or a leading wildcard (LIKE '%x') defeats it; fix with an expression index or by not wrapping the column.
  • 05A large estimate-vs-actual gap means stale statistics; ANALYZE fixes the planner's inputs.
  • 06Other classic causes: reading too much data, app-side N+1 queries, and sorts/hashes spilling past work_mem.
  • 07Indexes cost write speed and space — index the columns your frequent slow queries need, and use partial/covering indexes to target the cost.
  • 08Always 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

nextConnections & Pooling