Part of Managed PostgresPostgres · Performance

Find slow Postgres queries with pg_stat_statements and EXPLAIN ANALYZE.

Turn on pg_stat_statements and sort by total execution time: the top rows are where the database spends its time, often a fast query called thousands of times. Then run EXPLAIN (ANALYZE, BUFFERS) on one of them with real values, look for a sequential scan that discards most rows, and add the index it needs.

Facts checked

Turn on pg_stat_statements

pg_stat_statements ships with Postgres but is off until you load it. It needs shared memory, so it has to be in shared_preload_libraries, which takes a server restart. After that, CREATE EXTENSION in each database where you want to read the view. It records every statement with its constants replaced by $1, $2, so WHERE id = 7 and WHERE id = 8 count as one query.

# postgresql.conf (needs a restart)
shared_preload_libraries = 'pg_stat_statements'
compute_query_id = on

-- then, in each database where you want the view
CREATE EXTENSION pg_stat_statements;

The queries to run

On a server where you can read the view yourself, this is the one to start with. Sort by total time first. A 2 ms query called 400,000 times a day costs more than a 3-second report run twice.

-- Where the time goes: total time, with calls and the average
SELECT round(total_exec_time) AS total_ms,
       calls,
       round(mean_exec_time::numeric, 2) AS mean_ms,
       rows / nullif(calls, 0) AS rows_per_call,
       round(100.0 * shared_blks_hit / nullif(shared_blks_hit + shared_blks_read, 0), 1) AS cache_hit_pct,
       left(query, 120) AS query
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
ORDER BY total_exec_time DESC
LIMIT 15;

-- Start a fresh measurement after a fix
SELECT pg_stat_statements_reset();
What you seeWhat it usually meansLook at next
High total_exec_time, high mean_exec_time †A query that is slow every timeEXPLAIN (ANALYZE, BUFFERS) with real values
High total_exec_time, tiny mean, huge calls †The same lookup run in a loop: an N+1The code path, not the plan
High rows per call †Fetching far more than the page showsA LIMIT, fewer columns, or pagination by key
Low cache hit (shared_blks_read large next to shared_blks_hit) †Reading from disk, often a scan of a big tableAn index that narrows the scan, or memory
temp_blks_written above 0 †A sort or hash too big for work_mem, spilling to diskAn index that returns rows already sorted

† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. High `total_exec_time`, high `mean_exec_time` (2026-10-29) · High `total_exec_time`, tiny mean, huge `calls` (2026-10-29) · High `rows` per call (2026-10-29) · Low cache hit (`shared_blks_read` large next to `shared_blks_hit`) (2026-10-29) · `temp_blks_written` above 0 (2026-10-29)

Run EXPLAIN ANALYZE on the top query

pg_stat_statements shows $1 where the values were. Put real values back: the plan for a customer with 40 orders can differ from one with 400,000. Then ask Postgres how it ran the query.

-- Postgres 18 adds buffer counts to ANALYZE by itself; on 17, ask for them
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 4821 ORDER BY created_at DESC LIMIT 20;

-- ANALYZE really runs the statement. For writes, roll it back:
BEGIN;
EXPLAIN (ANALYZE, BUFFERS) UPDATE orders SET status = 'paid' WHERE id = 991;
ROLLBACK;

Reading the plan

A plan is a tree. Each line is a step, indented under the step that consumes its rows; the deepest lines run first. With ANALYZE each step shows two sets of numbers: the planner’s guess (cost, rows) and what happened (actual time, rows, loops).

-- Before (Postgres 17 output, trimmed)
Limit  (actual time=88.420..88.426 rows=20 loops=1)
  Buffers: shared hit=2113 read=6221
  ->  Sort  (actual time=88.418..88.421 rows=20 loops=1)
        Sort Key: created_at DESC
        ->  Seq Scan on orders  (cost=0.00..20834.00 rows=51 width=97)
                                (actual time=0.381..88.301 rows=48 loops=1)
              Filter: (customer_id = 4821)
              Rows Removed by Filter: 999952
Execution Time: 88.471 ms

-- The fix, as its own migration (CONCURRENTLY cannot run in a transaction)
CREATE INDEX CONCURRENTLY orders_customer_created_idx
  ON orders (customer_id, created_at DESC);

-- After
Limit  (actual time=0.032..0.061 rows=20 loops=1)
  Buffers: shared hit=24
  ->  Index Scan using orders_customer_created_idx on orders
        (actual time=0.031..0.057 rows=20 loops=1)
        Index Cond: (customer_id = 4821)
Execution Time: 0.083 ms
Look forWhat it tells you
Seq Scan with a large Rows Removed by Filter †Postgres read the whole table to keep a few rows. The classic missing index.
Estimated rows far from actual rows †Stale or thin statistics; the planner chose a plan for the wrong size. Run ANALYZE on the table.
loops above 1 on an inner step †That step ran once per outer row. Multiply its time by loops to get its real cost.
Sort Method that reports disk instead of memory †The sort did not fit in work_mem. An index in the same order can skip the sort.
Buffers: shared read much larger than hit †Data came from disk, not cache. Fewer pages read is the goal of most fixes.

† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. `Seq Scan` with a large `Rows Removed by Filter` (2026-10-29) · Estimated `rows` far from actual `rows` (2026-10-29) · `loops` above 1 on an inner step (2026-10-29) · `Sort Method` that reports disk instead of memory (2026-10-29) · `Buffers: shared read` much larger than `hit` (2026-10-29)

The common fixes

Build indexes with CREATE INDEX CONCURRENTLY on a live table so writes keep going, in a migration of its own outside a transaction. Where migrations should run covers how Rails and Django handle that.

  • Missing index on a filter or join column. Foreign keys are the usual gap: Postgres indexes the primary key, not the column that points to it. On Canner, canner db check lists unindexed foreign keys and duplicate indexes on every plan, from the catalog only.
  • N+1 queries. One query for the list, then one per row. The fix is in the ORM: includes or preload in Rails, select_related/prefetch_related in Django, include in Prisma, or a join you write yourself.
  • Deep `OFFSET` pagination. OFFSET 50000 reads and throws away 50,000 rows. Paginate by key instead: WHERE id < $last_seen ORDER BY id DESC LIMIT 20.
  • A function on the column. WHERE lower(email) = $1 can’t use an index on email. Index the expression: CREATE INDEX ON users (lower(email)).
  • Too much data per row. SELECT * on a table with big JSON or text columns moves all of it. Select the columns the page uses.
  • Statistics out of date after a bulk load. Run ANALYZE table_name and explain again.

What this setup does not give you

On Canner the counters run from when you turned monitoring on or last reset them, and the log keeps seven days. There is no long-term query history and no tracing from a slow request to the query behind it. If you need that, an APM tool in your app does it better.

On a database without the dedicated instance, pg_stat_statements is not available. EXPLAIN ANALYZE works anywhere you can run SQL, for example psql through external access, and canner db check still finds missing foreign-key indexes.

Questions

Why does pg_stat_statements say the relation does not exist?

The library is loaded server-wide, but the view only exists in databases where someone ran CREATE EXTENSION pg_stat_statements. Loading it also needs shared_preload_libraries and a restart.

Does EXPLAIN ANALYZE run the query?

Yes. It executes the statement and discards the output, so an UPDATE or DELETE changes data. Wrap writes in BEGIN and ROLLBACK. Plain EXPLAIN, without ANALYZE, only plans.

Should I sort by total time or mean time?

Total time first: it is what the database actually spent. Mean time finds the queries that are slow for each user. A query with a tiny mean and a huge call count is usually an N+1.

How do I find N+1 queries in Postgres?

In pg_stat_statements, look for a simple lookup with a very high calls count and a small mean time, often by primary key. Then find the loop in your code that runs it once per row.

Can I use query insights on Canner’s free plan?

Query insights and the Postgres log need the dedicated instance, which is $9 a month per project. On any plan you can run EXPLAIN ANALYZE and canner db check.

See your slowest queries on Canner.

Add the dedicated instance to a project, switch on query insights, and sort by total time.