Skip to main content
RunBook Academy

PostgreSQLX · Query Planning, Indexes and Performance MethodPlanner

A performance methodology

Advanced⏱ ~30 minpsql

What you'll learn

  • Establish what is slow before deciding why
  • Use pg_stat_statements to rank work by total cost rather than by complaint
  • Form and test one hypothesis at a time
  • Verify a change with a comparison rather than an impression

Prerequisites

Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27

Not yet marked complete on this device.

Everything in this part has been a technique. This lesson is the order to apply them in, because most unsuccessful performance work is competent techniques applied to the wrong thing.

Step 1: define what “slow” means

Before touching the database, get a specific claim. “The application is slow” cannot be investigated; the following can.

  • Which operation? A page, an endpoint, a job.
  • How slow, against what? p95 was 200 ms, is now 4 s.
  • Since when? A deploy, a data volume threshold, gradually.
  • All the time, or sometimes? Intermittent points at plan instability, lock waits or cache effects; consistent points at volume or a plan change.
  • Is the database implicated at all? Application-side time is often the answer, and hours are lost assuming otherwise.

Refusing to proceed without these is not pedantry. It is the difference between measuring and guessing.

Step 2: find where the time goes

pg_stat_statements is the single most valuable extension for this. Install it before you need it.

-- postgresql.conf: shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT round(total_exec_time)::bigint          AS total_ms,
       calls,
       round(mean_exec_time::numeric, 2)       AS mean_ms,
       round(stddev_exec_time::numeric, 2)     AS stddev_ms,
       shared_blks_hit + shared_blks_read      AS total_blocks,
       round(100.0 * total_exec_time
             / sum(total_exec_time) OVER (), 1) AS pct_of_total,
       left(query, 80)                          AS query
  FROM pg_stat_statements
 ORDER BY total_exec_time DESC
 LIMIT 20;

Step 3: check what sessions are waiting on

Before analysing a plan, establish that the problem is the plan.

SELECT wait_event_type, wait_event, state, count(*)
  FROM pg_stat_activity
 WHERE backend_type = 'client backend'
 GROUP BY 1, 2, 3
 ORDER BY count(*) DESC;
Dominant wait_event_typePoints atPart
LockBlocking, DDL, contentionIX
IOReads that are not cachedXI
LWLockInternal contention, often buffer mappingXI
ClientThe application is not reading resultsIV
null while activeGenuinely computingThis part

If sessions are waiting on Lock, no amount of query tuning helps. This step takes ten seconds and routinely redirects an entire investigation.

Step 4: examine one plan

Take the top query from step 2 and get its plan — from auto_explain if it is running in production, otherwise EXPLAIN (ANALYZE, BUFFERS) in the safe form from lesson X-03.

Then apply the procedure from lesson X-02:

  1. Read Buffers at the top. That is the total work.
  2. Find the node with the largest Buffers.
  3. Check its loops.
  4. Compare its estimate to its actual — bottom up, first divergence.
  5. Look at timing last.

Step 5: one hypothesis at a time

Write it down before testing it. A hypothesis has a predicted observation:

“The orders sequential scan is the cost. An index on (status, created_at) should reduce buffers from 16,667 to under 500.”

Then test exactly that. Changing three things at once and observing an improvement teaches you nothing about which of them mattered, and leaves two changes in production that may be costing you.

Step 6: verify with a comparison

-- before
EXPLAIN (ANALYZE, BUFFERS) SELECT …;

-- make exactly one change

-- after
EXPLAIN (ANALYZE, BUFFERS) SELECT …;

Compare buffers, not seconds. Timing varies with cache state, with concurrent load, and with what the storage happens to be doing. Buffer counts are deterministic for a given plan and data set, and a change that reduces buffers has reduced work on any hardware.

Then confirm it in production against the same measurement that identified the problem:

SELECT pg_stat_statements_reset();
-- wait for a representative period
-- re-run the step 2 query and compare total_exec_time for that statement

An improvement that shows in a hand-run EXPLAIN and not in pg_stat_statements has not helped the workload, whatever it did to the test case.

What to take from this

  • Get a specific claim before investigating. “Slow” is not one.
  • Rank by total_exec_time for capacity, by mean_exec_time for experience, by stddev for instability.
  • Check wait_event_type before analysing a plan. Ten seconds, and it redirects investigations.
  • One hypothesis, one change, one measurement.
  • Compare buffers, not seconds. Then confirm in pg_stat_statements.
  • Install pg_stat_statements and auto_explain now; they need a restart.

Cross-course references

  • Linux for Production Sysadmins — Part XXXVIII (Linux performance fundamentals) and Part LXXIX (Troubleshooting methodology) cover the same discipline one layer down: measure, form a hypothesis, change one thing, measure again.
  • Observability for Production Sysadmins — Part XCVIII (Troubleshooting methodology) and Part CII (Slow queries) cover starting from the user-visible latency rather than from the database’s own opinion of itself.
  • Git, CI/CD & GitOps — Part CXIV (Deployment markers) covers putting deploys on the same timeline as the latency, which answers “what changed” faster than any query does.

Quiz

Knowledge check · 6 questions

  1. Q1. pg_stat_statements shows query A at 4000 ms mean over 3 calls and query B at 40 ms mean over 200,000 calls. Which is consuming the server's capacity?

  2. Q2. An engineer spends two hours tuning a query's plan. It turns out sessions were overwhelmingly waiting on wait_event_type 'Lock'. Which step was skipped?

  3. Q3. After adding an index, a hand-run EXPLAIN ANALYZE is much faster but pg_stat_statements shows no improvement in that statement's total_exec_time. What is the most likely explanation?

  4. Q4. Which are genuine limitations of pg_stat_statements that affect how its output should be read? Select all that apply.

  5. Q5. A before-and-after comparison should be made on buffer counts rather than on elapsed time, because buffers are deterministic for a given plan and data set.

  6. Q6. List the six steps of the methodology in order and say what each one prevents.

Passing score: 75%. Answers are checked in this browser.