The tuning workflow
The systematic loop: find the slow query with pg_stat_statements, reproduce with EXPLAIN (ANALYZE, BUFFERS), diagnose the real cause, apply the smallest fix, verify, repeat. Measure, don't guess.
Two engineers get the same ticket: “the app is slow.” The first opens the codebase and starts adding indexes on every column a WHERE touches — three deploys later it’s no faster and writes are now slower. The second runs one query against pg_stat_statements, finds that 78% of total database time is one report query, EXPLAINs it, sees a misestimate, runs ANALYZE, and closes the ticket in twenty minutes. Same skill ceiling, opposite outcome. The difference is a loop, not a trick. This lesson is the loop that ties the whole unit together.
Step 1 — find the query that actually matters
You cannot tune what you haven’t measured, and intuition about “the slow query” is almost always wrong. The first move is pg_stat_statements — an extension that aggregates every executed query (normalized, with literals stripped) and tracks its cumulative cost. The number that matters is total_exec_time, not mean_exec_time: a 5 ms query run 2 million times costs more total database time than a 4-second report run twice, and shaving the former saves more.
-- The queries eating the most total time across the whole server:
SELECT
substring(query, 1, 60) AS query,
calls,
round(total_exec_time) AS total_ms,
round(mean_exec_time, 2) AS mean_ms,
round(100 * total_exec_time / sum(total_exec_time) OVER (), 1) AS pct
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;Read the pct column: tuning is Pareto. If one query is 78% of total time, that’s your whole afternoon; the rest is noise until it isn’t. Sort by total_exec_time, pick the top, ignore everything else.
Step 2–4 — reproduce, diagnose, fix the smallest thing
Once you have the query, the loop is mechanical:
- Reproduce with
EXPLAIN (ANALYZE, BUFFERS)(lesson 01). This is the ground truth — never tune from the query text alone. - Diagnose by matching the evidence to one root cause (the whole unit, compressed):
- Big estimated-vs-actual row gap? → stale or independence-assuming statistics →
ANALYZEorCREATE STATISTICS(lesson 03). - Seq Scan on a selective filter, high
read=buffers? → missing index → add one (CONCURRENTLY), per the databases indexes chapter. - Live rows tiny but buffers/size huge? → bloat → vacuum/
pg_repack(lesson 04). - Estimates fine, right scan, still slow? → genuinely too much data → rewrite, paginate, or pre-aggregate.
- Big estimated-vs-actual row gap? → stale or independence-assuming statistics →
- Apply the smallest fix that addresses that cause. One change. A targeted
ANALYZEbeats a new index; a new index beats a rewrite; a rewrite beats a config change — in that order of locality, because the smallest change has the least blast radius. - Verify by re-running
EXPLAIN (ANALYZE, BUFFERS)and confirming the number moved. Then re-checkpg_stat_statementsafter real traffic — sometimes the fix shifts the bottleneck to the next query, and the loop repeats.
Why “measure, don’t guess” is the whole discipline
Every anti-pattern in tuning is a guess that skipped a measurement step:
- Premature indexing — adding indexes “to be safe” before any query proves it needs one. Each index taxes every write (the databases indexes chapter quantifies it) and can mislead the planner; an unused index is pure cost. Add an index only after
EXPLAINshows the Seq Scan it would remove. - Cargo-cult config — copying
random_page_costorwork_memvalues from a blog without measuring your workload. - Rewriting blind — the junior in the hook rewrote SQL three times because the text felt slow, when the real cause was a stale estimate the rewrite couldn’t touch.
The senior reflex is the inverse: every change is preceded by a measurement that justifies it and followed by a measurement that confirms it. That’s what makes the loop converge instead of thrash. (The unit-09 capstone runs this exact loop end-to-end on a realistic analytics API — this lesson is the method it applies.)
▸Why this works
Why sort by total_exec_time and not mean_exec_time? Because you’re optimizing the server’s time budget, and total time is what users collectively wait. A query averaging 2 ms but called 50 million times a day burns far more aggregate time — and more lock contention, more buffer churn, more CPU — than a 30-second monthly report. Fixing the 2 ms query to 1 ms saves more wall-clock across all users than halving the report. mean_exec_time matters for a single user-facing request’s latency SLA, but for “where is the database spending its life?”, total dominates. Use both lenses: total to find the systemic cost, mean to protect a specific interactive endpoint.
Order the systematic tuning loop for a 'the app is slow' ticket:
- 1 Query pg_stat_statements; sort by total_exec_time to find the costliest query
- 2 Reproduce that query with EXPLAIN (ANALYZE, BUFFERS)
- 3 Diagnose the single root cause: misestimate, missing index, bloat, or too much data
- 4 Apply the smallest fix that addresses that cause (e.g. ANALYZE before adding an index)
- 5 Verify by re-running EXPLAIN and confirming the number actually moved
- 6 Re-check pg_stat_statements after traffic; loop back to the new top query
In pg_stat_statements, which column tells you where the database is spending the most time overall?
EXPLAIN ANALYZE shows a big estimated-vs-actual row gap and a nested loop. What's the smallest correct first fix?
- 01What is the first step of the tuning loop and which pg_stat_statements column do you sort by, and why?
- 02Given an EXPLAIN ANALYZE, how do you map evidence to the smallest fix?
- 03Why is 'measure, don't guess' the core discipline, and what anti-patterns does it prevent?
Tuning is a loop, and running it is what separates senior work from flailing. Find the query that actually matters with pg_stat_statements, sorted by total_exec_time — tuning is Pareto, and a cheap query run millions of times outweighs a rare expensive one. Reproduce it with EXPLAIN (ANALYZE, BUFFERS); never tune from the text. Diagnose a single root cause by reading the evidence — a big estimate-vs-actual gap means statistics (lesson 03), a Seq Scan on a selective filter means a missing index, tiny live rows in a huge table mean bloat (lesson 04), and a clean plan that’s still slow means genuinely too much data. Apply the smallest fix for that cause, in order of locality — ANALYZE before an index before a rewrite before config — because the least change has the least blast radius. Verify the number moved, then loop back, since the fix often promotes the next query to the top. The thread tying it together is measure, don’t guess: every change is preceded by a measurement that justifies it and followed by one that confirms it. Now when you get a “the app is slow” ticket, you know the first query to run isn’t in your codebase — it’s against pg_stat_statements.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.