Slow queries are inevitable. At some point, the table that was fine at 10k rows starts causing timeouts at 10M rows. Here's how I diagnose and fix them.
Never guess. Always start with EXPLAIN ANALYZE:
EXPLAIN ANALYZE
SELECT u.*, p.title
FROM users u
JOIN posts p ON p.user_id = u.id
WHERE u.created_at > '2024-01-01'
ORDER BY p.created_at DESC
LIMIT 20;The output tells you:
If you see Seq Scan on a large table, you probably need an index:
-- Before: Seq Scan on users
SELECT * FROM users WHERE email = '[email protected]';
-- Add the index
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
-- After: Index ScanUse CONCURRENTLY to avoid locking the table during index creation in production.
Order matters. Put the most selective column first, and match your query's WHERE clause order:
-- Query filters by status AND created_at
SELECT * FROM orders
WHERE status = 'pending' AND created_at > NOW() - INTERVAL '7 days';
-- Good composite index
CREATE INDEX idx_orders_status_created
ON orders(status, created_at DESC);The silent killer of ORM-based apps. Instead of fetching related data in a loop:
-- Bad: 1 query to get users + N queries for their posts
SELECT * FROM users WHERE active = true;
-- then for each user:
SELECT * FROM posts WHERE user_id = $1;Use a JOIN or a single WHERE IN:
-- Good: one query
SELECT u.id, u.name, json_agg(p.*) as posts
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
WHERE u.active = true
GROUP BY u.id, u.name;Index only the rows you actually query:
-- Only index active users (not the millions of inactive ones)
CREATE INDEX idx_users_email_active
ON users(email)
WHERE active = true;
-- Queries with WHERE active = true will use this smaller, faster index
SELECT * FROM users WHERE email = $1 AND active = true;PostgreSQL's query planner uses statistics to estimate row counts. If stats are stale, the planner makes bad decisions:
-- Update statistics manually
ANALYZE users;
-- Check when stats were last updated
SELECT schemaname, tablename, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE tablename = 'users';In production, make sure autovacuum is enabled and tuned. For high-write tables, you may need more aggressive settings:
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_analyze_scale_factor = 0.005
);Database connections are expensive. In production, always use a connection pooler:
Most "slow query" problems at scale are actually connection exhaustion — the app is waiting for a connection, not for the query itself.
When a query is slow, run through this list:
EXPLAIN ANALYZE — identify the bottleneckWHERE / JOIN columns?ANALYZE tablename)* when you only need 3 columns?Most slow queries are fixed by one of the first three.