Skip to main content

How to Find the One Query That Is Actually Killing You

6 min read
Find my costliest query

We turn on pg_stat_statements, rank queries by total time and fix the one at the top.

The query that is hurting you is usually not the slow one. It is the fast one that runs two million times an hour. pg_stat_statements ranks by total time rather than by how a query feels, which is why it finds the real answer in about ten minutes. Here is how to turn it on, which columns to sort by and why, the two configuration defaults that quietly give you nothing, and what to do with the query once you have it.

When a database is struggling, the instinct is to hunt for the slow query. It is usually the wrong hunt.

A report that takes twenty seconds and runs twice a day costs your database forty seconds. A lookup that takes three milliseconds and runs two million times an hour costs it a hundred minutes. The second one never appears in a slow query log, and it is the one flattening your server.

Ranking by total time instead of by duration is the whole technique, and Postgres ships the tool for it.

Turn it on

pg_stat_statements is a contrib module. It needs shared memory, so it has to be preloaded, which means one restart.

# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
compute_query_id = on
pg_stat_statements.max = 10000
pg_stat_statements.track = all

Then, once, in the database you care about:

CREATE EXTENSION pg_stat_statements;

Two defaults are worth knowing, because both quietly give you less than you expect.

pg_stat_statements.max defaults to 5000. That is the number of distinct statements kept; once it is full, the least executed entries are discarded. On a busy application with many query shapes, raising it costs a little shared memory and stops your evidence being evicted before you look at it.

pg_stat_statements.track_planning defaults to off. The planning columns exist either way, and they will read zero until you turn it on. If you are chasing a suspicion that planning rather than execution is the cost, enable it deliberately; it is off by default because it has overhead.

The query that finds the query

SELECT
  calls,
  round(total_exec_time::numeric, 1) AS total_ms,
  round(mean_exec_time::numeric, 2)  AS mean_ms,
  rows,
  query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

Sort by total_exec_time, not mean_exec_time. Mean tells you which query feels slow. Total tells you where the server's time went, and those are different lists more often than not.

Reset the counters before a representative window so you are measuring today rather than everything since the last restart:

SELECT pg_stat_statements_reset();

Leave it an hour under normal load, then run the ranking. The top three usually account for most of the pain, and the first one is often a surprise.

Reading the result

High calls, low mean_ms. An N+1. Something in the application is looping over rows and querying once per row. The fix is in the application, not the database: batch it into one query with IN, or fetch the relation up front. No index will save you from ten thousand round trips.

Low calls, high mean_ms. A genuine slow query. This one is worth an EXPLAIN.

rows far larger than anything a screen shows. Something is fetching everything and filtering in application code. Move the filter into SQL.

Two entries that look identical. They differ in a literal that was not parameterised, so they are separate shapes. That is a signal in itself: parameterise, and the plan gets cached.

Then explain it

Take the top query and ask the planner what it actually did.

EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

ANALYZE runs the query and reports real timings rather than estimates. BUFFERS shows how many blocks came from cache and how many from disk, which is often the whole story: the same query is fine at three in the afternoon and terrible at nine in the morning because the cache is cold.

Look for a sequential scan on a large table, an estimated row count wildly different from the actual one, and a sort that spilled to disk.

Be careful with EXPLAIN (ANALYZE) on writes. It executes the statement. Wrap it in a transaction you roll back.

Catch the ones that only misbehave in production

Some queries are only slow with production data volumes and production cache pressure. auto_explain logs the plan for anything over a threshold, so the evidence is waiting for you rather than needing to be reproduced.

shared_preload_libraries = 'pg_stat_statements,auto_explain'
auto_explain.log_min_duration = '500ms'
auto_explain.log_analyze = on

log_analyze adds real execution costs to the logged plan, and adds overhead to every statement that crosses the threshold. Set the threshold high enough that it only catches genuine outliers.

The order

Turn on the module, reset, wait an hour under real load, rank by total time, explain the top three. That is usually ten minutes of work and it replaces a week of guessing.

If the answer turns out to be the server rather than the query, that is a different job, and it is the one our server optimization work starts with.

Frequently asked questions

Why does the Postgres slow query log miss the query that is overloading the database?

The query doing the damage is often fast and frequent. A report that takes twenty seconds and runs twice a day costs the database forty seconds, while a three millisecond lookup run two million times an hour costs it a hundred minutes and never appears in a slow query log. Ranking statements by total time with pg_stat_statements finds the second kind.

How do I enable pg_stat_statements in PostgreSQL?

Add shared_preload_libraries = 'pg_stat_statements' and compute_query_id = on to postgresql.conf and restart once, since the module needs shared memory and has to be preloaded. Then run CREATE EXTENSION pg_stat_statements in the database being investigated. Raising pg_stat_statements.max to 10000 keeps more distinct statements before the least executed are discarded, and pg_stat_statements.track = all goes in the same file.

Should pg_stat_statements be sorted by total_exec_time or mean_exec_time?

Sort by total_exec_time. Mean time shows which query feels slow, while total time shows where the server's time actually went, and the two lists often differ. Reset the counters first with SELECT pg_stat_statements_reset(), leave the database an hour under normal load, then rank with ORDER BY total_exec_time DESC LIMIT 20. The top three usually account for most of the pain.

Which pg_stat_statements defaults give less data than expected?

The two to watch are pg_stat_statements.max and pg_stat_statements.track_planning. pg_stat_statements.max defaults to 5000 distinct statements, and once full the least executed entries are discarded, so a busy application can lose evidence before anyone looks. Raising it costs a little shared memory. track_planning defaults to off, which leaves the planning columns at zero until it is enabled, and it stays off by default because it adds overhead.

How do I spot an N+1 query in pg_stat_statements?

An entry with very high calls and a low mean_ms is the signature of an N+1. The application is looping over rows and querying once per row, and no index will save ten thousand round trips. The fix belongs in the application, either batching the lookups into one query with IN or fetching the relation up front.

What should I look for in PostgreSQL EXPLAIN (ANALYZE, BUFFERS) output?

Check for a sequential scan on a large table, an estimated row count wildly different from the actual one, and a sort that spilled to disk. ANALYZE runs the query and reports real timings, and BUFFERS shows how many blocks came from cache and how many from disk. That split often explains a query that is fine at three in the afternoon and terrible at nine in the morning, when the cache is cold.

Is it safe to run EXPLAIN ANALYZE on an UPDATE or DELETE?

Only inside a transaction that is rolled back afterwards. EXPLAIN (ANALYZE) executes the statement for real in order to report actual timings, so an UPDATE or DELETE under it changes data exactly as it would on its own. Open a transaction, run the EXPLAIN, read the plan, then roll the transaction back.

How can PostgreSQL log the plans of slow queries in production automatically?

Load auto_explain alongside pg_stat_statements with shared_preload_libraries = 'pg_stat_statements,auto_explain' and set auto_explain.log_min_duration = '500ms'. Plans for anything over the threshold are then logged, so the evidence is already waiting when a query misbehaves. auto_explain.log_analyze = on adds real execution costs to the logged plan, with overhead on every statement that crosses the threshold, so keep the threshold high enough to catch only genuine outliers.

Read how we handle it in Cloud & Server Management.