How to Find and Fix Slow Queries in PostgreSQL with pg_stat_statements

intro

Your API latency graph spikes every afternoon. You know it's the database, but which query? The slow trace in your APM tool points at one endpoint, and that endpoint fires six queries. The application logs tell you when things were slow, but you’re seaching for what PostgreSQL actually spent its time on. pgstatstatements answers that question. It records execution stats for every query the server runs, so you can rank them by the time they truly cost and start with the worst offender.

This walks through enabling the extension, finding the queries that hurt, reading the numbers correctly, and turning a flagged query into a fix you can measure.

Turn on pg_stat_statements

pg_stat_statements ships with PostgreSQL, but it isn't active by default. It can be enabled via the configuration file. To enable it, edit postgresql.conf and specify how many statements to track at any given time (pg_stat_statements.max):

Copy
        
1 shared_preload_libraries = 'pg_stat_statements' 2 pg_stat_statements.max = 10000 3 pg_stat_statements.track = top

Restart PostgreSQL, then create the pg_stat_statements extension in the database you want to inspect. This needs a superuser or a role with CREATE privilege on the database:

Copy
        
1 CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

After creating the extension, you might want to confirm it’s actually working in conjunction with your database by issuing a query like so:

Copy
        
1 SELECT count(*) FROM pg_stat_statements;

If you see a number bigger than zero, you should assume the extension is doing its job. The full list of configuration knobs lives in the PostgreSQL pg_stat_statements docs.

Rank queries by total time, not by the slowest single run

To find any queries that took a long time to execute, you might want to rank them by cumulative time first:

Copy
        
1 SELECT 2 query, 3 calls, 4 total_exec_time, 5 mean_exec_time, 6 rows 7 FROM pg_stat_statements 8 ORDER BY total_exec_time DESC 9 LIMIT 10;

You might want to start from this query because it will show you the top 10 queries where your database actually spends the most of its time.

One version note. PostgreSQL 13 renamed the timing columns. On 13 and later, use total_exec_time and mean_exec_time. On 12 and earlier the columns are total_time and mean_time. If the query above errors with "column does not exist", you're on an older release, so drop the _exec.

Read the columns that matter

total_exec_time and calls give you the query execution cost for the database. The rest tell you why a query is slow. Read the following columns:

  1. calls: how often the query ran. High calls with a low mean point at application behavior, like a query running inside a loop.
  2. mean_exec_time: average milliseconds per call. A high mean on a frequent query is your prime tuning target.
  3. rows: total rows returned across all calls. Dividing rows by calls tells you whether a query pulls back far more than the app needs.
  4. shared_blks_hit and shared_blks_read: blocks served from cache versus read from disk. A query reading heavily from disk is a candidate for an index or a tighter result set.

Cache behavior hides across those last two columns, so compute the hit ratio directly:

Copy
        
1 SELECT 2 query, 3 calls, 4 round(total_exec_time::numeric, 1) AS total_ms, 5 shared_blks_hit, 6 shared_blks_read, 7 round( 8 100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0), 9 1 10 ) AS cache_hit_pct 11 FROM pg_stat_statements 12 ORDER BY total_exec_time DESC 13 LIMIT 10;

Here, a query near the top with a low cache_hit_pct is reading from disk on most calls since it skips most of the query cache and a fix would include adding a missing index or a query touching less data.

From a flagged query to a fix

Say the top row is this, with a high mean time and a low cache hit ratio:

Copy
        
1 SELECT * FROM orders WHERE customer_id = $1 AND status = $2

To know why such a query is expensive, run it through EXPLAIN with real values and ask for the plan plus the buffer counts:

Copy
        
1 EXPLAIN (ANALYZE, BUFFERS) 2 SELECT * FROM orders WHERE customer_id = 1234 AND status = 'shipped';

If the plan shows a sequential scan over a large table, PostgreSQL is reading every row to find the matches:

Copy
        
1 Seq Scan on orders (cost=0.00..18540.00 rows=120 width=84) 2 (actual time=0.021..95.432 rows=118 loops=1) 3 Filter: ((customer_id = 1234) AND (status = 'shipped'::text)) 4 **Rows Removed by Filter: 999882** 5 Buffers: shared read=8540

Read into the Rows Removed by Filter column: the query reads a million rows to return 118. To fix such a query, you would need to add an index that matches the filter:

Copy
        
1 CREATE INDEX idx_orders_customer_status 2 ON orders (customer_id, status);

Run the same EXPLAIN again. The sequential scan becomes an index scan, the disk reads turn into cache hits, and actual time drops from 95ms to under 1ms:

Copy
        
1 Index Scan using idx_orders_customer_status on orders 2 (actual time=0.038..0.211 rows=118 loops=1) 3 Buffers: shared hit=122

Column order in a composite index matters here, and our guide to the PostgreSQL index mechanism covers how to pick it. If parsing nested plan text gets old, DbVisualizer’s explain plan viewer renders the tree with per-node timing and row counts, which makes the expensive node easier to spot. For a deeper read on the plan output itself, see using the EXPLAIN plan in PostgreSQL.

Reset the stats to measure your change

pg_stat_statements accumulates statements from the last reset. If you’re measuring anything, do reset the counters, let normal traffic run, then re-check the ranking:

Copy
        
1 SELECT pg_stat_statements_reset();

Run your top-10 query again a few hours later. The query you tuned should have fallen down the list, or its mean_exec_time should have dropped. That before-and-after is the proof your change worked.

What pg_stat_statements won't tell you

pg_stat_statements tracks execution statistics for all SQL queries running within PostgreSQL. That means that:

  • pg_stat_statements won’t show which specific query leaves your application hanging. In other words, it will not record any failing queries since it only tracks statements that have completed successfully.
  • pg_stat_statements provides aggregate statistics, meaning that it won’t provide activity that’s currently going on within your database.
  • pg_stat_statements won’t tell you when a specific spike in traffic happened and will ignore statements executed internally in PG/pgSQL functions.

Start with the cumulative-time ranking on your busiest database. The top five rows almost always include one query you didn't know was expensive: that’s your starting point.

Practical use cases

Some practical use cases of pg_stat_statements include:

  • Tracing a CPU spike to a statement. When the database is busy, sort by total_exec_time and read the top three SQL queries provided. One of them is usually driving the load, and you have the exact statement to tune.
  • Coding mistakes. A query with hundreds of thousands of calls and a tiny mean_exec_time is often fired once a while in application code. The fix lives in the app, and calls is what points you there.
  • Confirming a missing index. A top query with a low cache hit ratio and a high shared_blks_read is reading from disk on most runs. Pair it with EXPLAIN and you usually find a sequential scan.
  • Checking a deploy for regressions. Reset the stats right before a release, let traffic run, then re-check the ranking. A query near the top that wasn't there yesterday is a regression you can catch the same day.

Frequently asked questions

Does pg_stat_statements slow down my database?

No. The overhead coming with pg_stat_statements is small and most production systems run it full time. Each statement does a little extra bookkeeping, and the cost grows with the number of distinct query shapes. That count is capped by pg_stat_statements.max, so raise that limit rather than turning tracking down.

Do the statistics survive a restart?

Yes. PostgreSQL saves the stats to disk on shutdown and reloads them on start. They reset only when you call pg_stat_statements_reset() or remove the stats file.

Why don't the times provided by pg_stat_statements match with my logs or application?

pg_stat_statements measures time that your SQL queries have spent “moving” inside PostgreSQL. It doesn't include network round trips, connection pool waits, or the time your app spends processing the result, hence the mismatch.

Can I use it on Amazon RDS or other managed Postgres?

Yes. Most managed providers either preload it or let you enable it through a parameter group. Check the parameter group for shared_preload_libraries before assuming you need to change anything.

Can I change the amount of queries I track with pg_stat_statements?

Yes. Change the amount of queries monitored by fiddling with the value in pg_stat_statements.max (default: 5000.)

Dbvis download link img
About the author
TheTable
TheTable

The Table by DbVisualizer is where we gather together to learn about and simplify the complexity of working with database technologies.

The Table Icon
Sign up to receive The Table's roundup
More from the table
Title Author Tags Length Published
title

How to Connect to and Work with MariaDB in DbVisualizer (2026 Guide)

author Lukas Vileikis tags MARIADB SQL 7 min 2026-11-02
title

Proprietary vs. Open Source Database Tools: A Comparison

author Lukas Vileikis tags SQL 4 min 2026-10-26
title

SQL Window Functions: Running Totals, Rankings, and Deduplication

author TheTable tags 7 min 2026-10-19
title

Best Oracle SQL Developer Alternatives for 2026 (Windows & macOS)

author Leslie S. Gyamfi tags Recommendations SQL clients 6 min 2026-10-12
title

SQL Interview Questions and Answers: Part 3: Tackling Advanced Scenarios

author Lukas Vileikis tags SQL 8 min 2026-10-05
title

How to Delete Duplicate Rows in SQL?

author Lukas Vileikis tags SQL 6 min 2026-09-21
title

SQL vs NoSQL Databases: A Practical Guide to Choosing the Right Database for Your Use Case

author Leslie S. Gyamfi tags Database system NOSQL SQL 13 min 2026-09-14
title

SQL for Data Analytics: 5 Advanced Techniques You Should Know

author Lukas Vileikis tags MySQL SQL 5 min 2026-09-07
title

Understanding and Using the MOD Function in SQL

author Antonello Zanini tags MySQL ORACLE POSTGRESQL SQL SQL SERVER 8 min 2026-08-31

The content provided on dbvis.com/thetable, including but not limited to code and examples, is intended for educational and informational purposes only. We do not make any warranties or representations of any kind. Read more here.