A Postgres process killed by the OOM killer is dramatic. The application starts erroring. The log shows nothing because Postgres did not get to write a log line. The host's dmesg shows the SIGKILL.
Most OOMs are preventable. The pattern is almost always one of three things, and the diagnostic does not take long.
How Postgres uses memory
Four main allocations:
shared_buffers: the cache. Fixed at startup, typically 25% of RAM.work_mem: per-operation memory for sorts, hash tables, etc. Allocated per active operation.maintenance_work_mem: for VACUUM, CREATE INDEX, REINDEX. Allocated per maintenance operation.- Per-connection overhead: ~10MB per backend for catalog cache, plan cache, etc.
Total memory used at any moment is approximately:
shared_buffers
+ work_mem * (concurrent operations)
+ maintenance_work_mem * (concurrent maintenance ops)
+ per_connection_overhead * connections
The trap: work_mem is per-operation, not per-connection. A single query can use multiple work_mem chunks (one per sort, hash join, etc.). For a query with five hash joins, that is 5x work_mem.
The math people forget
100 connections × 5 operations per query × 256MB work_mem = 128GB of memory just for sorts and hashes.
If the server has 32GB, this leads to OOM. The kernel notices the process is consuming more than the server has and SIGKILLs it.
Most OOMs come from this calculation being silently wrong.
The diagnostic
After an OOM, the questions:
1. What was the connection count?
Look at the application's connection pool settings, multiplied by instance count. If 100 instances each have 50 connections, that is 5000 connections.
-- If the server is back up, current count
SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend';
For cloud instances, look at the historical metrics for connection count at the time of the OOM.
2. What is work_mem?
SHOW work_mem;
If it is set globally above 16MB, multiply by realistic concurrent operations to estimate peak memory.
3. Were there any heavy queries running?
From pg_stat_statements (or logs), look for queries that were active around the OOM with high memory implications. Especially:
- Large sorts (look for
Sortplans withexternal merge). - Hash joins with
Batches > 1. - Large array_agg or json_agg operations.
The fixes
1. Lower work_mem. Default is 4MB. For most production OLTP, 8-16MB is plenty. Don't go above 32MB unless you have profiled.
2. Cap max_connections. Lower this AND/OR run a connection pooler.
A pooler (PgBouncer) lets the application open many connections to the pooler while the pooler maintains a small number to Postgres. The total Postgres backend count is bounded.
3. Set per-session work_mem for heavy queries.
SET LOCAL work_mem = '256MB';
SELECT ... heavy aggregation ...;
Let specific queries use more memory without inflating the global default.
4. Cap maintenance_work_mem per instance.
VACUUM and REINDEX use this. Default is fine; aggressively-set high values can cause OOM if multiple maintenance ops run concurrently.
Operational settings that help
-- Enforce a per-statement memory cap (Postgres 14+)
ALTER SYSTEM SET hash_mem_multiplier = 1.0; -- default; raise carefully
-- Cap the total connections strictly
ALTER SYSTEM SET max_connections = 200;
-- Reasonable default work_mem
ALTER SYSTEM SET work_mem = '16MB';
For cloud instances, the OS-level oom-score-adjust matters too. Postgres should not be the kernel's first choice to kill. On managed services (RDS, Cloud SQL), this is handled.
The shared_buffers question
A common mistake: setting shared_buffers very high (50%+ of RAM) on the theory that more cache is better.
Reality: above ~30%, Postgres's bookkeeping overhead grows faster than the cache benefit. The kernel page cache is also helping cache; double-caching wastes memory.
For 16GB+ instances, 25-30% is the right range. For smaller instances (<8GB), even lower.
After an OOM
The sequence:
- Postgres restarts (managed services do this; self-managed needs a watchdog).
- Replication catches up if the restart was from the primary.
- Application reconnects.
- Investigate root cause to prevent recurrence.
The investigation is the important part. Without it, the OOM repeats.
A pragmatic checklist
For any production Postgres:
work_mem≤ 32MB globally. Raise per-session for known heavy queries.max_connectionscapped at a sane number for the instance size.- PgBouncer in front for connection multiplexing.
maintenance_work_mem≤ 1GB.- Memory monitoring alert at 80% utilization (not 95% — that is too late).
- Killable / non-essential workloads (analytics, batch jobs) on separate instances or replicas.
This adds up to a buffer between normal usage and OOM. Most workloads stay comfortably below the limit.
A worked example
A team I helped had a 32GB RDS instance hitting OOM weekly. Their settings:
shared_buffers: 8GB (25%, fine)work_mem: 256MB (high)max_connections: 1000 (very high)- No PgBouncer
Math: 1000 connections × 256MB × 3 (typical operations per query) = 768GB potential. Add per-connection overhead and they were sitting on a memory bomb that occasionally went off.
Fix:
- Drop
work_memto 16MB globally. - For specific analytical queries,
SET LOCAL work_mem = '512MB'per session. - Drop
max_connectionsto 200. - Add PgBouncer in front.
New math: 200 × 16MB × 3 = ~10GB peak. Comfortable on 32GB RAM.
No more OOMs. Application performance improved (less memory pressure means more efficient cache use).
The fix was config-only. The team had been thinking about upgrading instance size; the real issue was the settings were dangerous regardless of size.