open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 08 · 06

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.

SQL Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

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 → ANALYZE or CREATE 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.
  • Apply the smallest fix that addresses that cause. One change. A targeted ANALYZE beats 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-check pg_stat_statements after 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 EXPLAIN shows the Seq Scan it would remove.
  • Cargo-cult config — copying random_page_cost or work_mem values 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 steps

Order the systematic tuning loop for a 'the app is slow' ticket:

  1. 1 Query pg_stat_statements; sort by total_exec_time to find the costliest query
  2. 2 Reproduce that query with EXPLAIN (ANALYZE, BUFFERS)
  3. 3 Diagnose the single root cause: misestimate, missing index, bloat, or too much data
  4. 4 Apply the smallest fix that addresses that cause (e.g. ANALYZE before adding an index)
  5. 5 Verify by re-running EXPLAIN and confirming the number actually moved
  6. 6 Re-check pg_stat_statements after traffic; loop back to the new top query
Quiz

In pg_stat_statements, which column tells you where the database is spending the most time overall?

Quiz

EXPLAIN ANALYZE shows a big estimated-vs-actual row gap and a nested loop. What's the smallest correct first fix?

↻ latency
Recall before you leave
  1. 01
    What is the first step of the tuning loop and which pg_stat_statements column do you sort by, and why?
  2. 02
    Given an EXPLAIN ANALYZE, how do you map evidence to the smallest fix?
  3. 03
    Why is 'measure, don't guess' the core discipline, and what anti-patterns does it prevent?
Recap

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.

recallapplystretch0 of 8 done
Connected lessons

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.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.