The PostgreSQL documentation lies to you about memory settings. I've tuned hundreds of production instances, and the "recommended" settings will fuck your performance more often than help it. Here's what actually works based on real production experience and PostgreSQL performance tuning research.
Shared Buffers: Ignore the 25% Rule
The shared_buffers documentation says set shared_buffers
to 25% of RAM. That's garbage advice that comes from 2005, as discussed in PostgreSQL performance tuning guides. On modern systems with 32GB+ RAM, you're wasting memory and creating OS cache competition.
What actually works according to production tuning experience:
- 8-16GB RAM: 25% is fine (2-4GB)
- 32GB+ RAM: 15-20% maximum (4-6GB on 32GB system)
- High-write workloads: Even lower, around 10-15%
I've seen systems with 64GB RAM where dropping shared_buffers
from 16GB to 8GB improved performance by 40%. The OS filesystem cache is more efficient than PostgreSQL's buffer pool for most read workloads, as explained in buffer management internals.
## Good starting point for 32GB system
shared_buffers = 4GB
effective_cache_size = 24GB # 75% of total RAM
Work Memory: The Setting That Breaks Everything
`work_mem` controls memory for sorting, hashing, and bitmap operations per operation per connection. Set this too high and you'll run out of memory. Too low and PostgreSQL hits disk for every complex query. The PostgreSQL query execution internals explain why this setting is so critical.
The math that matters:
- Each connection can use
work_mem
multiple times for complex queries - A 5-table join might use 4x
work_mem
per connection - 100 connections × 4MB × 4 operations = 1.6GB just for query operations
Real production settings:
- OLTP workloads: 2-4MB (lots of simple queries)
- Analytics/reporting: 16-64MB (complex queries, fewer connections)
- Mixed workloads: 8-16MB with connection pooling
The danger zone is setting work_mem
above 64MB without understanding your query patterns. I've seen systems OOM because someone set it to 256MB and a reporting query spawned 10 sort operations.
Connection Memory Overhead Reality Check
Every PostgreSQL connection is a separate process that consumes:
- Base overhead: 2-4MB per connection
- work_mem: Multiplied by active operations
- temp_buffers: 8MB default per connection
- Backend memory: 1-2MB for connection state
The calculation everyone gets wrong:
Real memory per connection = 4MB + (work_mem × avg_operations) + temp_buffers
With work_mem=16MB
and average 2 operations per query:
- Per connection: 4MB + (16MB × 2) + 8MB = 40MB
- 100 connections = 4GB just for connection overhead
- 500 connections = 20GB before you store any actual data
This is why connection pooling isn't optional above 100 connections.
Maintenance Work Memory: Don't Be Cheap
maintenance_work_mem
affects VACUUM, CREATE INDEX, and REINDEX operations. The default 64MB is pathetic for production systems.
Set it to 5-10% of total RAM or 1-2GB, whichever is smaller. Large maintenance_work_mem
dramatically speeds up:
- Index creation (can be 3x faster with 2GB vs 64MB)
- VACUUM operations on large tables
- pg_dump performance
## For 32GB system
maintenance_work_mem = 2GB
autovacuum_work_mem = 1GB # Separate setting for autovacuum
WAL Buffer Size: Small Setting, Big Impact
wal_buffers
determines WAL (Write-Ahead Log) buffer size. Too small and PostgreSQL flushes to disk constantly. Too large and you waste memory.
Formula that works: shared_buffers / 32
but between 16MB and 64MB.
For most systems, 16-32MB is perfect. Going above 64MB rarely helps and wastes memory that could be used for OS cache.
Checkpoint Configuration: Control the Pain
Checkpoints flush dirty pages to disk and can cause massive I/O spikes. Bad checkpoint tuning creates the classic "why does my database pause for 10 seconds every few minutes" problem.
## Spread checkpoints over time instead of I/O spikes
checkpoint_completion_target = 0.9
checkpoint_timeout = 15min
## Use more WAL segments to avoid frequent checkpoints
max_wal_size = 4GB # Increase if you have write-heavy workloads
min_wal_size = 1GB
checkpoint_completion_target = 0.9
spreads the checkpoint I/O over 90% of the checkpoint interval instead of doing it all at once. This prevents the I/O storms that make your application timeout.
Real Configuration Example
Here's a production configuration for a 32GB RAM system running mixed OLTP/analytics:
## Memory settings
shared_buffers = 6GB
work_mem = 8MB
maintenance_work_mem = 2GB
effective_cache_size = 24GB
temp_buffers = 32MB
## WAL settings
wal_buffers = 32MB
checkpoint_completion_target = 0.9
max_wal_size = 4GB
min_wal_size = 1GB
## Connection and background processes
max_connections = 200 # With connection pooling
autovacuum_work_mem = 1GB
This configuration assumes you're using connection pooling (mandatory) and have monitoring in place. Adjust work_mem
down for higher connection counts, up for fewer concurrent users with complex queries.
Monitoring Memory Usage
Don't guess if your memory configuration works. Monitor these metrics using tools like pg_stat_statements, pgbadger, or Grafana dashboards:
-- Check buffer hit ratios (want >95%)
SELECT
schemaname,
tablename,
heap_blks_read,
heap_blks_hit,
round((heap_blks_hit::numeric / (heap_blks_hit + heap_blks_read)) * 100, 2) as hit_ratio
FROM pg_statio_user_tables
WHERE heap_blks_read > 0
ORDER BY hit_ratio ASC;
-- Monitor connection memory usage
SELECT
pid,
usename,
application_name,
state,
query_start,
resident_set_size,
virtual_memory_size
FROM pg_stat_activity a
JOIN lateral (
SELECT (string_to_array(replace(pg_backend_memory_contexts(a.pid), E'
', ' '), ' '))[2]::bigint as resident_set_size,
(string_to_array(replace(pg_backend_memory_contexts(a.pid), E'
', ' '), ' '))[4]::bigint as virtual_memory_size
) mem ON true
WHERE state = 'active';
If your buffer hit ratio is below 95%, you need more shared_buffers
or effective_cache_size
. If connections are using more memory than expected, reduce work_mem
or implement connection pooling.