Programming

How to Diagnose and Fix Slow PostgreSQL Queries

Tech Setup5 min read
TS

Tech Setup

Published September 29, 2026 · Editorial policy

How to Diagnose and Fix Slow PostgreSQL Queries

Your backend is crawling because an endpoint that used to return in 50 milliseconds is now timing out after 30 seconds. You are a mid-level or senior engineer managing a Postgres-backed service in production, and you are staring at a dashboard full of pg_stat_activity spikes while your deployment pipeline is frozen. You need to stop guessing, find the exact query execution bottleneck, and apply an index or rewrite strategy that brings your latency back down before your users notice.

The 60-Second Emergency Triage

Stop reading query logs blindly. Log into your production database terminal immediately and run this command to isolate the query locking up your connection pool right now:

SELECT 
    pid, 
    age(clock_timestamp(), query_start) AS duration, 
    usename, 
    state, 
    query 
FROM pg_stat_activity 
WHERE state <> 'idle' 
  AND query NOT LIKE '%pg_stat_activity%'
ORDER BY duration DESC 
LIMIT 5;

Expected Output:

  pid  |    duration     | usename | state  |                           query                           
-------+-----------------+---------+--------+-----------------------------------------------------------
 41822 | 00:00:14.238911 | app_user| active | SELECT * FROM orders WHERE customer_id = 94821...
 41790 | 00:00:03.110294 | app_user| active | SELECT * FROM audit_logs ORDER BY created_at DESC...
(2 rows)

If you see a query dominating the duration column, do not kill it yet. You need to inspect its execution plan first. Note the pid or copy the query text for the next step.

Step 1: Force PostgreSQL to Reveal the Truth with EXPLAIN ANALYZE

Never run EXPLAIN without ANALYZE in production unless you are certain the query is a write operation or an extremely heavy aggregation that will lock tables. EXPLAIN only guesses based on planner statistics, which are often stale. EXPLAIN ANALYZE actually executes the query, times every node, and shows you where the database spent its CPU cycles and I/O wait time.

Run this command in your interactive terminal (psql):

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) 
SELECT o.id, o.total, c.email 
FROM orders o 
JOIN customers c ON o.customer_id = c.id 
WHERE o.status = 'pending' 
  AND o.created_at >= NOW() - INTERVAL '7 days';

Expected Output:

                                                        QUERY PLAN                                                         
---------------------------------------------------------------------------------------------------------------------------
 Hash Join  (cost=44211.85..89321.40 rows=1523 width=45) (actual time=452.122..1241.532 rows=1420 loops=1)
   Hash Cond: (o.customer_id = c.id)
   Buffers: shared hit=12403 read=31022 written=0
   ->  Seq Scan on orders o  (cost=0.00..44102.10 rows=1523 width=37) (actual time=0.012..1120.451 rows=1420 loops=1)
         Filter: ((status = 'pending'::text) AND (created_at >= (now() - '7 days'::interval)))
         Rows Removed by Filter: 1200000
         Buffers: shared hit=421 read=28010
   ->  Hash Build  (cost=31200.00..31200.00 xxxx rows=500000 width=20) (actual time=441.211..441.211 rows=500000 loops=1)
         Buffers: shared hit=11982 read=3012

Decodifying the Output

Look at two critical metrics in this execution plan:

  1. Seq Scan on orders o: Postgres is reading every single page of the orders table from disk (read=28010 disk blocks instead of memory cache hits). It scanned 1.2 million rows just to filter down to 1,420 matching records. This is a classic full-table scan.
  2. Buffers: shared read vs hit: High read numbers indicate disk I/O bottlenecks. If your working set doesn't fit in shared_buffers, Postgres has to hit the OS file cache or disk storage, introducing massive latency.

Step 2: Build the Right Index (Not Just Any Index)

Developers often slap a single-column index on every foreign key and call it a day. That will not save you here. Because your query filters on both status and created_at, a single-column index on status is useless if 90% of your orders have status = 'pending', and a single-column index on created_at forces Postgres to check the table separately for the status.

You need a composite partial index tailored to the exact access pattern of your query.

Execute this command:

CREATE INDEX CONCURRENTLY idx_orders_pending_recent 
ON orders (created_at, customer_id) 
WHERE status = 'pending';

Note on CONCURRENTLY: Always use this flag in production. Running CREATE INDEX without it places an exclusive lock on the table, blocking all writes and updates until the index build completes.

Why This Index Works

  • Partial Index (WHERE status = 'pending'): It only indexes rows that match your active workflow. If you have 50 million historical completed or cancelled orders, Postgres ignores them entirely, keeping the index small, lightning-fast, and resident in RAM.
  • Column Order: Put equality checks or range boundaries first depending on cardinality, but generally, place the column used for range filtering (created_at >= ...) first, followed by the join or lookup columns (customer_id).

Re-run your EXPLAIN (ANALYZE, BUFFERS) query. You should now see an Index Scan or Bitmap Heap Scan with drastically lower startup costs and execution times under 5 milliseconds.

Step 3: Handle the Silent Killers (Bloat and Planner Stats)

Sometimes your indexes are correct, your query is optimized, but Postgres still chooses a slow sequential scan. This happens when your table statistics are stale or your tables suffer from severe physical bloat.

Fixing Outdated Statistics

Postgres relies on the ANALYZE daemon (autovacuum) to update statistics about data distribution in tables. If your application just imported a massive batch of data, the planner doesn't know what rows are where.

Force an immediate update with distribution sampling:

ALTER TABLE orders SET (autovacuum_analyze_scale_factor = 0.05);
ANALYZE VERBOSE orders;

Checking for Table and Index Bloat

Over time, frequent UPDATE and DELETE operations leave dead tuples inside data pages. Postgres must read these bloated pages from disk even if they contain no live data.

Install the pgstattuple extension to inspect actual physical bloat:

CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT * FROM pgstattuple('orders');

Expected Output:

-[ RECORD 1 ]------+----------------
table_len          | 145981440
tuple_count        | 1205420
tuple_len          | 98214200
tuple_percent      | 67.28
dead_tuple_count   | 412000
dead_tuple_percent | 28.21
free_space         | 12400200
free_percent       | 8.49

If your dead_tuple_percent exceeds 20% and autovacuum isn't keeping up, your database requires manual intervention. Run a full table rebuild:

REINDEX TABLE CONCURRENTLY orders;
VACUUM FULL orders;

Warning: VACUUM FULL locks the table exclusively. Never run it during peak traffic hours.

If This Doesn't Work: The 3 Most Common Failure Points

If you applied a composite index, updated your statistics, and your query is still timing out, check these three architectural traps:

  1. Implicit Type Casting in Predicates If your orders.customer_id column is a UUID or BIGINT, but your application passes the parameter as a string (WHERE customer_id = '12345'), Postgres must implicitly cast every row's column value to text before comparing. This completely invalidates B-Tree indexes.

    • Fix: Ensure your ORM or driver parameters match the exact database column types.
  2. Function Calls on Indexed Columns Writing WHERE DATE(created_at) = '2023-10-01' makes the index on created_at invisible to the planner because the function wraps the column.

    • Fix: Rewrite the range predicate to be sargable (Search Argument Able): WHERE created_at >= '2023-10-01 00:00:00' AND created_at < '2023-10-02 00:00:00'.
  3. Connection Pooling and statement_timeout Misconfigurations If your API server uses a naive connection pool without query timeouts, a single unindexed report query can hold a backend worker hostage indefinitely, exhausting your pool and causing cascading failures across unrelated microservices.

    • Fix: Set a global or session-level safety net in your connection string or postgresql.conf:
      SET statement_timeout = '10s';
      

Prevention and Long-Term Monitoring

Manual firefighting wastes engineering hours. To catch slow queries before they hit production users, enable the built-in query statistics collector in your postgresql.conf file:

shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = all
pg_stat_statements.max = 10000
track_io_timing = on

Restart your Postgres service, and install the extension:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

Now, run this query weekly to find your database's worst offenders ranked by total cumulative execution time:

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

By pairing pg_stat_statements with disciplined index creation and sargable query design, you transform database performance tuning from a guessing game into a repeatable engineering process.