
PostgreSQL Performance Tuning: 10 Techniques That Actually Work
After tuning queries from 8 seconds down to 80ms, here are the 10 techniques that moved the needle β with real EXPLAIN ANALYZE output and index trade-off tables.
Web Performance Deep Dives
Part 1 of 3
Practical investigations into database and infrastructure performance for modern web stacks.
Table of Contents
Why Most Slow Queries Are Avoidable
In my experience, 80% of slow PostgreSQL queries come from three things: missing indexes on join/filter columns, N+1 query patterns from ORMs, and SELECT * on wide tables. The remaining 20% require deeper investigation β but fixing these three first almost always reaches acceptable performance without schema changes.
ββPremature optimization is the root of all evil. But measuring first and optimizing second is just engineering.β
β Martin Fowler
The 10 Techniques
Ordered by impact-to-effort ratio. Technique 1 frequently solves 70% of the problem by itself.
- 1Add composite indexes for your most-queried column combinations
- 2Run EXPLAIN (ANALYZE, BUFFERS) before writing any optimization β measure first
- 3Replace SELECT * with explicit column lists to reduce I/O and memory
- 4Eliminate N+1 queries with JOINs or batch fetching (IN clause with IDs)
- 5Use partial indexes to index only rows you actually query
- 6Enable connection pooling with PgBouncer to reduce per-connection overhead
- 7Set work_mem appropriately for complex sorts and hash joins
- 8Run VACUUM ANALYZE after bulk inserts to update planner statistics
- 9Partition large tables by date range for time-series data
- 10Cache frequently-read, rarely-written data in Redis to bypass the database entirely
Using EXPLAIN ANALYZE
Before and after every optimization, run EXPLAIN (ANALYZE, BUFFERS). The key signals: 'Seq Scan' means a full table scan (usually bad), 'Index Scan' means the planner is using your index.
Before: Sequential Scan (8.2 seconds)
1-- Slow query: no index on user_id + status combination2EXPLAIN (ANALYZE, BUFFERS)3SELECT id, amount, created_at4FROM orders5WHERE user_id = 12345 AND status = 'completed'6ORDER BY created_at DESC7LIMIT 20;89-- Result:10-- Seq Scan on orders (actual time=8198.23..8199.01 rows=20)11-- Filter: ((user_id = 12345) AND (status = 'completed'))12-- Rows Removed by Filter: 1,847,293After: Composite Partial Index (0.8ms)
1-- Composite partial index: covers the filter, sort, and WHERE clause2CREATE INDEX CONCURRENTLY idx_orders_user_completed3ON orders (user_id, created_at DESC)4WHERE status = 'completed';56-- Index Scan using idx_orders_user_completed7-- (actual time=0.72..0.81 rows=20)Choosing the Right Index Type
| Index Type | Best For | Avoid When | Write Overhead |
|---|---|---|---|
B-tree (default) | Equality, range, ORDER BY | Array/JSONB containment | Low |
GIN | Full-text search, JSONB, arrays | High write rate | High |
GiST | Geometric data, range types | Simple equality queries | Medium |
BRIN | Very large sequential tables (timestamps) | Random-access patterns | Very low |
Hash | Exact equality on a single column | Range queries, ORDER BY | Very low |
VACUUM Is Not Optional
If you bulk-insert or bulk-delete rows (>100k), run VACUUM ANALYZE immediately after. PostgreSQL's query planner relies on table statistics β stale stats lead to catastrophically bad query plans even with perfect indexes.
Real Production Results
Applying techniques 1β4 to TaskFlow's orders query reduced average response time from 8.2 seconds to 82 milliseconds β a 100Γ improvement with zero application code changes.
For Go applications, the pgoptimize library (in the related tools section) wraps EXPLAIN ANALYZE output parsing and can automatically suggest missing indexes based on your query patterns.
Related Articles
Continue your learning journey with these handpicked articles.

Related Content
Explore related articles, projects, and tools.