M
MeshWorld.
HowTo PostgreSQL SQL Performance Database Query Optimization Backend Developer Tools 6 min read

How to Debug a Slow SQL Query in PostgreSQL

By Vishnu Damwala

Your app is slow. Users are complaining. You suspect it’s the database. Here’s how to find the problem and fix it.


Step 1: Find which queries are slow

Before you can fix slow queries, you need to know which ones they are. PostgreSQL’s pg_stat_statements extension tracks query performance across all executions.

Enable pg_stat_statements

-- Check if it's already enabled
SELECT * FROM pg_extension WHERE extname = 'pg_stat_statements';

-- Enable it (requires superuser, and a restart or reload)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

-- In postgresql.conf, add:
-- shared_preload_libraries = 'pg_stat_statements'
-- Then: sudo systemctl restart postgresql

Find the slowest queries

-- Top 10 slowest queries by total time
SELECT
  round(total_exec_time::numeric, 2) AS total_ms,
  calls,
  round(mean_exec_time::numeric, 2) AS avg_ms,
  round((total_exec_time / sum(total_exec_time) OVER ()) * 100, 2) AS pct,
  query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

This shows you what’s actually costing the most time in production — not what you guessed.


Step 2: Run EXPLAIN ANALYZE on the problem query

Once you know which query is slow, run EXPLAIN ANALYZE to see the query plan:

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.*, u.name, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'pending'
ORDER BY o.created_at DESC
LIMIT 50;

Read the output

Sort  (cost=5432.10..5432.23 rows=50) (actual time=234.567..234.589 rows=50 loops=1)
  Sort Key: o.created_at DESC
  Sort Method: top-N heapsort  Memory: 34kB
  ->  Hash Join  (cost=1842.00..5421.00 rows=4484) (actual time=45.123..230.456 rows=4484 loops=1)
        Hash Cond: (o.user_id = u.id)
        ->  Seq Scan on orders o  (cost=0.00..3200.00 rows=4484) (actual time=0.021..180.234 rows=4484 loops=1)
              Filter: ((status)::text = 'pending')
              Rows Removed by Filter: 95516
        ->  Hash  (cost=1200.00..1200.00 rows=50000) (actual time=40.000..40.000 rows=50000 loops=1)

What to look for:

Red flagMeaning
Seq Scan on large tableNo index — full table scan
Rows Removed by Filter: 95000Scanning 100k rows to get 4k — needs index
actual time >> estimated timeStale stats — run ANALYZE tablename
Nested Loop with high row countsJoin might be missing an index
Sort Method: external merge DiskSort spilling to disk — increase work_mem

Step 3: Fix the most common problems

Missing index — the most common cause

-- The query is doing Seq Scan + Filter on status
-- Fix: add an index
CREATE INDEX CONCURRENTLY ON orders(status);

-- Better: add index on status + created_at (covers the ORDER BY too)
CREATE INDEX CONCURRENTLY ON orders(status, created_at DESC);

-- Now EXPLAIN ANALYZE shows Index Scan instead of Seq Scan

CONCURRENTLY builds the index without locking the table — safe for production.

N+1 queries — the silent killer

This isn’t in one SQL query — it’s in your application code:

// This fires 1 + N queries (one for orders, one per user)
const orders = await db.query('SELECT * FROM orders WHERE status = $1', ['pending']);
for (const order of orders) {
  order.user = await db.query('SELECT * FROM users WHERE id = $1', [order.user_id]);
}

Fix: join at the database level:

SELECT o.*, u.name, u.email
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.status = 'pending';

Or use WHERE id IN (...) to batch load:

const userIds = [...new Set(orders.map(o => o.user_id))];
const users = await db.query('SELECT * FROM users WHERE id = ANY($1)', [userIds]);
const userMap = Object.fromEntries(users.map(u => [u.id, u]));
orders.forEach(o => o.user = userMap[o.user_id]);

Stale statistics — when the plan is just wrong

PostgreSQL’s query planner uses statistics about your data to choose query plans. If the statistics are old, it makes bad decisions.

-- Update statistics for a specific table
ANALYZE orders;

-- Update all tables
ANALYZE;

-- Check when statistics were last updated
SELECT relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY last_analyze NULLS FIRST;

Too much data in the WHERE clause scan

-- Slow: function on indexed column breaks the index
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- (index on email can't be used because of LOWER())

-- Fast option 1: functional index
CREATE INDEX ON users(LOWER(email));

-- Fast option 2: use ILIKE or case-insensitive collation
SELECT * FROM users WHERE email ILIKE '[email protected]';

-- Fast option 3: store emails lowercase at write time

Slow JOIN — missing index on the foreign key

-- Seq Scan on orders for every user (classic)
SELECT u.name, COUNT(o.id)
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;

-- Fix: index on the foreign key
CREATE INDEX CONCURRENTLY ON orders(user_id);

Step 4: Check for locks and blocking queries

Sometimes the query itself is fast — it’s just waiting for a lock.

-- Show queries waiting for locks
SELECT
  pid,
  now() - query_start AS wait_duration,
  state,
  wait_event_type,
  wait_event,
  query
FROM pg_stat_activity
WHERE wait_event IS NOT NULL
  AND state != 'idle'
ORDER BY wait_duration DESC;

-- Show the full lock dependency chain
SELECT
  blocked.pid AS blocked_pid,
  blocked.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;

-- Kill the blocking query (graceful)
SELECT pg_cancel_backend(blocking_pid);

-- Kill it hard if cancel doesn't work
SELECT pg_terminate_backend(blocking_pid);

Step 5: Look at connection counts

Too many connections can slow everything down — PostgreSQL has overhead per connection.

-- Current connections
SELECT count(*), state FROM pg_stat_activity GROUP BY state;

-- Max connections
SHOW max_connections;

-- If you're close to the limit, you need a connection pooler
-- (PgBouncer is the standard solution)

Quick diagnostic checklist

-- 1. Top slow queries
SELECT round(mean_exec_time::numeric, 1) AS avg_ms, calls, query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 10;

-- 2. Tables without autovacuum running recently
SELECT relname, last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE last_autovacuum < NOW() - INTERVAL '1 day'
   OR last_autovacuum IS NULL;

-- 3. Table sizes (are any unexpectedly huge?)
SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC;

-- 4. Unused indexes (candidates for removal)
SELECT schemaname, relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

-- 5. Cache hit ratio (should be > 99%)
SELECT
  round(sum(heap_blks_hit) * 100.0 / (sum(heap_blks_hit) + sum(heap_blks_read)), 2) AS cache_hit_pct
FROM pg_statio_user_tables;

The process in one sentence

Find the slow query with pg_stat_statements → understand why it’s slow with EXPLAIN ANALYZE → add an index, fix an N+1, or rewrite the query → verify with EXPLAIN ANALYZE again.

Most slow queries have one of four causes: missing index, N+1 pattern, stale statistics, or lock contention. Work through them in that order.

Keep these open while you debug: PostgreSQL Cheat Sheet | Run PostgreSQL Locally with Docker