PostgreSQL Performance Optimization: A Practical Guide
A field-tested playbook covering query tuning, configuration, metrics, and the tools that make slow PostgreSQL databases fast.
Quick Navigation
Difficulty: Intermediate
Estimated Time: 20-30 minutes
Prerequisites: Basic SQL knowledge, Familiarity with PostgreSQL, Understanding of database indexes, Command line experience
Introduction
PostgreSQL is one of the most capable open-source databases available, but its default configuration is intentionally conservative — it must run on everything from a Raspberry Pi to a 1TB-RAM production server. Real performance comes from understanding how Postgres executes queries, what it caches, and which knobs to turn. This guide walks through the techniques, configurations, metrics, and tools that consistently yield the biggest wins in production.
1. Start Where the Pain Is: EXPLAIN ANALYZE
Before tuning anything, measure. EXPLAIN ANALYZE runs the query and reports the actual execution plan with timings.
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT u.id, u.email, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > NOW() - INTERVAL '30 days'
GROUP BY u.id, u.email
ORDER BY order_count DESC
LIMIT 100;
2. Configuration Tuning (postgresql.conf)
The defaults assume a 128 MB machine. Here are the parameters that matter most, with sensible starting points for a server with 16 GB RAM.
| Parameter | Default | Recommended | Why |
|---|---|---|---|
shared_buffers | 128MB | 4GB (~25% of RAM) | Postgres's primary cache for table/index pages |
effective_cache_size | 4GB | 12GB (~75% of RAM) | Planner hint for total OS + Postgres cache |
work_mem | 4MB | 32–64MB | Per-operation memory for sorts/hashes |
maintenance_work_mem | 64MB | 1GB | For VACUUM, CREATE INDEX, ALTER TABLE |
wal_buffers | -1 (auto) | 16MB | Buffer for write-ahead log |
checkpoint_completion_target | 0.9 | 0.9 | Spread checkpoint I/O over time |
max_wal_size | 1GB | 4GB | Reduce checkpoint frequency on write-heavy loads |
random_page_cost | 4.0 | 1.1 (on SSD) | Tells planner SSDs are nearly as fast as sequential |
effective_io_concurrency | 1 | 200 (on SSD) | Enables parallel prefetching |
max_connections | 100 | 100–200 + PgBouncer | More isn't better; use a pooler |
default_statistics_target | 100 | 100–500 | Better planner stats for complex queries |
Critical caveat on work_mem: it's allocated per operation per connection, not globally. A single query with 3 sorts and 2 hashes across 100 connections could use work_mem × 5 × 100. Size it accordingly.
Example snippet for postgresql.conf:
shared_buffers = 4GB
effective_cache_size = 12GB
work_mem = 32MB
maintenance_work_mem = 1GB
wal_buffers = 16MB
max_wal_size = 4GB
checkpoint_completion_target = 0.9
random_page_cost = 1.1
effective_io_concurrency = 200
default_statistics_target = 100
After changing these, reload (no restart needed for most):
SELECT pg_reload_conf();
3. Partitioning Huge Tables
Once a table crosses ~100 GB or ~100M rows, queries and maintenance start to suffer. Native declarative partitioning (Postgres 10+) helps.
CREATE TABLE events (
id BIGSERIAL,
user_id BIGINT NOT NULL,
event_type TEXT,
created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2025_q1 PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
CREATE TABLE events_2025_q2 PARTITION OF events
FOR VALUES FROM ('2025-04-01') TO ('2025-07-01');
Benefits: partition pruning skips irrelevant partitions, dropping old data is DROP TABLE (instant) instead of DELETE (expensive), and VACUUM operates on smaller chunks.
For automatic partition management, use the pg_partman extension.
4. Schema and Data Design
- Right-size data types —
smallintoverint,timestamptzovertext,uuidnatively overvarchar(36). - Normalize for writes, denormalize selectively for reads — materialized views or summary tables for heavy aggregations.
- Partition large tables (declarative partitioning by range/list) for time-series or tenant data.
- JSONB with GIN indexes when fields are queried often; promote hot fields to real columns.
5. Bloat and Maintenance
- Tune autovacuum to be more aggressive on high-churn tables (lower
autovacuum_vacuum_scale_factor). pg_repackto rebuild bloated tables and indexes online.REINDEX CONCURRENTLYto rebuild bloated indexes without locking.
6. Connection and Execution Layer
- PgBouncer in transaction mode to cap backend count and reduce per-connection memory.
- Prepared statements to skip parse/plan overhead on repeated queries.
- Batch inserts with
COPYor multi-rowINSERTinstead of row-by-row. - Fix N+1 query patterns at the application level (eager loading, joins, IN lookups).
7. Metrics to Track in Production
You can't optimize what you don't measure. Monitor these continuously:
Cache hit ratio — should be > 99% for OLTP:
SELECT sum(heap_blks_hit)::float / NULLIF(sum(heap_blks_hit) + sum(heap_blks_read), 0) AS cache_hit_ratio
FROM pg_statio_user_tables;
Top queries by total time — install pg_stat_statements:
-- Add to postgresql.conf: shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION pg_stat_statements;
SELECT query, calls, total_exec_time, mean_exec_time, rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Long-running queries — anything over 30 seconds usually deserves attention:
SELECT pid, now() - query_start AS duration, state, query
FROM pg_stat_activity
WHERE state != 'idle' AND now() - query_start > INTERVAL '30 seconds'
ORDER BY duration DESC;
A Worked Example: Diagnosing a Slow Endpoint
A real pattern: an API endpoint serving "recent orders for a user" is timing out at 8s. Walk through it:
1. Capture the plan:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 12345 ORDER BY created_at DESC LIMIT 20;
Output shows: Seq Scan on orders (cost=0..487231) ... Rows Removed by Filter: 9,999,980.
2. No usable index. Add one:
CREATE INDEX CONCURRENTLY idx_orders_user_created
ON orders(user_id, created_at DESC);
3. Re-run EXPLAIN. Now: Index Scan using idx_orders_user_created ... actual time=0.05..0.3 ms.
4. Verify under load with pg_stat_statements. The query's mean_exec_time drops from 8000ms to under 1ms. Endpoint p99 falls from 9s to 40ms.
This loop — measure, hypothesize, change one thing, re-measure — is the entire game.
Conclusion
PostgreSQL rewards engineers who treat it as a system, not a black box. Read your plans, index intentionally, tune the handful of config values that actually matter, keep autovacuum healthy, and put monitoring in place before you need it. Most production database problems trace back to a missing index, a runaway query, or an unconfigured server — all of which are solvable in an afternoon once you know where to look.
Tags: #PostgreSQL #Database #Performance #DevOps #BackendEngineering #SQL #DataEngineering