A 2026 Postgres tuning cheat sheet. The 20% of knobs and tactics that produce 80% of the perf you can squeeze without rewriting your app.
Config knobs that matter
For a 32 GB / 8 vCPU server:
# postgresql.conf
shared_buffers = 8GB # ~25% of RAM
effective_cache_size = 24GB # ~75% of RAM (planner hint)
work_mem = 64MB # per sort/hash operation
maintenance_work_mem = 2GB # for VACUUM, CREATE INDEX
wal_buffers = 64MB
checkpoint_timeout = 15min
checkpoint_completion_target = 0.9
default_statistics_target = 100
random_page_cost = 1.1 # for SSD
effective_io_concurrency = 200 # for SSD/cloud disk
max_wal_size = 4GB
min_wal_size = 1GB
Postgres 18’s async I/O (Postgres 18 ) uses these:
io_method = io_uring # Linux 5.1+
io_max_concurrency = 16
Diagnostic queries
Slowest queries
SELECT
substring(query, 1, 80) AS q,
calls,
total_exec_time,
mean_exec_time,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
Indexes that aren’t being used
SELECT s.relname AS table, i.indexrelname AS idx, idx_scan
FROM pg_stat_user_indexes i
JOIN pg_stat_user_tables s ON s.relid = i.relid
WHERE idx_scan = 0
ORDER BY pg_relation_size(i.indexrelid) DESC;
Drop indexes with 0 scans and meaningful size.
Tables with high dead tuple ratio
SELECT relname,
n_dead_tup,
n_live_tup,
round(n_dead_tup::numeric / NULLIF(n_live_tup, 0), 2) AS dead_ratio
FROM pg_stat_user_tables
ORDER BY dead_ratio DESC NULLS LAST
LIMIT 10;
If dead_ratio is high, autovacuum isn’t keeping up. Tune.
Long-running transactions
SELECT pid, now() - xact_start AS duration, state, query
FROM pg_stat_activity
WHERE state != 'idle' AND xact_start IS NOT NULL
ORDER BY duration DESC;
Long transactions block vacuum across the cluster. Investigate or kill.
Reading EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 42;
What to look for:
- Seq Scan on a big table: missing index.
- Sort Method: external merge:
work_memtoo low. - Buffers: shared read=N: cold cache; ideally
shared hit=Nafter warmup. - Rows estimate vs actual: bad statistics;
ANALYZEthe table. - Filter + 1M rows removed: missing index for that filter column.
Indexing rules
- B-tree: equality, range, ORDER BY.
- GIN: array containment, jsonb @>, full-text search.
- GiST: range types, geo, full-text alternatives.
- BRIN: huge time-series; tiny indexes for time-based scans.
- HNSW: vectors (pgvector). See pgvector Deep Dive .
Composite index column order: equality first, range last:
CREATE INDEX orders_lookup ON orders (customer_id, status, created_at DESC);
-- works for: WHERE customer_id = ? AND status = 'paid' ORDER BY created_at DESC
Partial indexes for selective predicates:
CREATE INDEX orders_pending ON orders (created_at) WHERE status = 'pending';
For deeper indexing patterns: PostgreSQL Indexing and EXPLAIN .
Vacuum
Autovacuum runs by default; tune if it can’t keep up:
autovacuum_naptime = 30s
autovacuum_vacuum_scale_factor = 0.05 # vacuum at 5% dead tuples (default 20%)
autovacuum_analyze_scale_factor = 0.02
autovacuum_max_workers = 6
Per-table override for hot tables:
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01);
Avoid VACUUM FULL in production — it locks the table. Use pg_repack instead.
Connection pooling
Postgres connections cost ~10 MB each. Don’t open thousands. Use PgBouncer in transaction mode:
pool_mode = transaction
max_client_conn = 10000
default_pool_size = 25
10k clients → 25 backend connections. Scales serverless cleanly.
Read replicas
Reads to async replicas reduce primary load. Application routing:
read_pool = create_pool(replica_dsn)
write_pool = create_pool(primary_dsn)
async def list_orders():
return await read_pool.fetch("...") # eventually consistent OK
async def create_order(...):
return await write_pool.execute("...")
For read-after-write consistency, send those reads to primary.
What surprises new tuners
work_memis per operation, not per query. A query with a sort + a hash uses2 × work_mem.- Statistics matter as much as indexes. Stale
pg_statsproduces bad plans. - Vacuum is critical. Without it, dead tuples accumulate, indexes bloat, queries slow.
- Connections cost. 10 connections at 10 GB RAM is fine; 1000 isn’t.
- Cold cache is real. First query after restart is slow; pre-warm important tables.
What I’d tune first
For a slow Postgres:
- Run pg_stat_statements — find the top 5 slow queries.
- EXPLAIN each — index missing? Stats stale?
- Set shared_buffers + work_mem to sane values.
- Add a connection pooler if you don’t have one.
- Check vacuum — high dead tuples? Tune autovacuum.
In that order. Each step often resolves the slowness with no rewrite.
Read this next
- PostgreSQL Indexing and EXPLAIN
- PostgreSQL 18 Features
- PostgreSQL MVCC, Isolation, Locking
- Postgres Partitioning in 2026
If you want my Postgres tuning runbook, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .