Introduction
PostgreSQL is a powerhouse for relational data, but even the best engines can stumble when queries aren’t tuned. In this post we’ll walk through concrete steps to identify bottlenecks, apply indexing tricks, rewrite SQL, and tweak server settings. By the end you’ll have a checklist you can run on any production database.
1. Diagnose the Slow Query
1.1 Use EXPLAIN (ANALYZE, BUFFERS)
EXPLAIN (ANALYZE, BUFFERS)
SELECT u.id, u.email, COUNT(p.id) AS post_cnt
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
WHERE u.created_at > '2023-01-01'
GROUP BY u.id;
- The output shows actual execution time, rows processed, and buffer hits. Look for:
- Seq Scan on large tables → likely missing index.
- Nested Loop with high row counts → consider hash or merge joins.
-
Sort operations → may need an index that matches the
ORDER BY.
1.2 Identify Hot Spots with pg_stat_statements
SELECT query,
calls,
total_time,
mean_time,
rows
FROM pg_stat_statements
ORDER BY total_time DESC
LIMIT 5;
Focus on the top‑5 queries that consume the most CPU time.
2. Indexing Strategies
2.1 Single‑Column Index
CREATE INDEX idx_users_created_at ON users (created_at);
Speeds up the WHERE u.created_at > … filter.
2.2 Multi‑Column (Covering) Index
If you frequently group by u.id and need email, a covering index can avoid a heap fetch:
CREATE INDEX idx_users_id_email_created_at ON users (id, email, created_at);
2.3 Partial Index for Frequently Queried Subset
CREATE INDEX idx_active_posts ON posts (user_id)
WHERE status = 'published';
Only rows that match the predicate are indexed, keeping the index small and fast.
3. Query Refactoring
3.1 Remove Redundant Columns
Selecting columns you never use forces PostgreSQL to fetch extra data. Trim the SELECT list.
3.2 Use DISTINCT ON Instead of GROUP BY When Appropriate
SELECT DISTINCT ON (u.id) u.id, u.email, p.created_at
FROM users u
JOIN posts p ON p.user_id = u.id
WHERE u.active = true
ORDER BY u.id, p.created_at DESC;
This returns the latest post per user without a costly aggregation.
4. Server‑Side Tweaks
| Parameter | Typical Value | When to Adjust |
|---|---|---|
shared_buffers |
25% of RAM | Low memory → increase for larger caches |
work_mem |
64MB (per operation) | Complex joins, sorts, hashes |
effective_cache_size |
75% of RAM | Guides planner on available OS cache |
max_parallel_workers_per_gather |
2‑4 | Parallel query enabled on large tables |
After each change, rerun EXPLAIN (ANALYZE) to verify improvement.
5. Step‑by‑Step Troubleshooting Checklist
-
Run
EXPLAIN (ANALYZE)– note scans, joins, sorts. -
Check
pg_stat_statements– prioritize high‑cost queries. - Add/adjust indexes – start with predicates and join columns.
-
Refactor SQL – replace
GROUP BYwithDISTINCT ONif possible. -
Tune
work_memandshared_buffers– monitorpg_stat_activityfor spill‑to‑disk warnings. - Re‑measure – ensure total execution time drops >20% before moving on.
- Repeat – performance tuning is iterative.
6. Automation Helper
To speed up repetitive tuning, we built a small script that scans pg_stat_statements, suggests missing indexes, and applies safe defaults. Download the pre‑configured script here. It also generates a markdown report you can attach to your incident tickets.
If you prefer a one‑click solution, Get the complete patch tool which runs the recommendations in a transaction‑safe manner.
For the full source and community contributions, Access the full repository fix.
Conclusion
Optimizing PostgreSQL queries is a blend of observability, smart indexing, SQL craftsmanship, and system tuning. By following the diagnostic steps, applying the right indexes, and fine‑tuning server parameters, you can routinely shave seconds—or even minutes—off query latency.
Happy querying!
Top comments (0)