Pool Size and the Total work_mem Budget #
work_mem looks like a per-query setting and behaves like a per-node, per-backend, per-worker one. That difference is why a value chosen to fix one report can exhaust a server’s memory once a connection pool multiplies it across a hundred backends.
This page gives the arithmetic that turns a proposed work_mem into a worst-case figure you can compare against actual RAM. The pooling context is in connection pooling and plan cache.
The Multiplication Chain #
Four factors compound, in this order:
worst case ≈ work_mem
× memory-hungry nodes per plan
× hash_mem_multiplier (for hash-based nodes: hash join, hash aggregate)
× (parallel workers + 1)
× concurrent backends running such plans
A worked example makes the scale obvious. With work_mem = 64MB, a reporting plan containing one sort and two hash nodes, hash_mem_multiplier = 2.0, four parallel workers, and twenty concurrent report sessions:
sort: 64 MB × 1 node = 64 MB
hash nodes: 64 MB × 2.0 × 2 nodes = 256 MB
per backend: 320 MB
× 5 participants (4 workers + leader) = 1600 MB
× 20 concurrent sessions = 32000 MB ≈ 31 GB
Thirty-one gigabytes from a setting that reads as “64 MB”. And this sits on top of shared_buffers, the OS page cache, and every other backend’s baseline footprint.
Annotated Evidence #
Count the memory-hungry nodes directly in the plan — every one of them can hold an allowance at the same time:
Finalize GroupAggregate (actual rows=118422 loops=1)
-> Gather Merge (actual rows=473688 loops=1)
Workers Planned: 4 Workers Launched: 4 -- ×5 participants
-> Sort (actual rows=94738 loops=5)
Sort Method: quicksort Memory: 61204kB -- allowance #1
-> Partial HashAggregate (actual rows=94738 loops=5)
Peak Memory Usage: 128402 kB -- allowance #2 (×2.0 multiplier)
-> Hash Join (actual rows=824066 loops=5)
-> Hash (actual rows=200000 loops=5)
Batches: 1 Memory Usage: 121804kB -- allowance #3
Execution Time: 4102.1 ms
Three memory nodes, five participants: this single query held roughly 1.5 GB while it ran. loops=5 on each of those nodes is the multiplier made visible — every participant executed its own copy.
Then check how many such queries can run at once:
SHOW max_connections; -- server ceiling
SELECT count(*) FROM pg_stat_activity WHERE state = 'active';
-- and, at the pooler:
-- PgBouncer: SHOW POOLS; → the server-side connection count is what matters
Behind a transaction-mode pooler the number that matters is the server-side pool size, not the client count. Ten thousand clients sharing forty server connections can hold at most forty simultaneous allowances of each node.
Step-by-Step Budgeting #
-
Take three representative plans — the heaviest report, the typical page query, and the heaviest write path.
-
Count nodes that claim an allowance:
Sort,Hash,HashAggregate,Bitmap Heap Scan(its bitmap), and materialised CTEs. -
Apply the hash multiplier to the hash-based ones:
SHOW hash_mem_multiplier; -- default 2.0 -
Multiply by parallel participants for any plan containing a
Gather, usingWorkers Planned + 1. -
Multiply by the realistic concurrency, which behind a pooler is the server-side pool size:
; pgbouncer.ini default_pool_size = 40 max_client_conn = 10000 -
Compare against RAM minus
shared_buffersand OS overhead. If the worst case exceeds it, lower the allowance or the concurrency rather than hoping the peak never arrives. -
Scope generous values to the workload that needs them:
ALTER ROLE analytics SET work_mem = '256MB'; -- reporting only -- or, per statement: BEGIN; SET LOCAL work_mem = '512MB'; -- heavy query here COMMIT;SET LOCALis essential behind a pooler — a plainSETleaks to the next client that borrows the connection, which is exactly the session parameter drift problem.
Before and After #
-- BEFORE: global work_mem raised to 256MB to fix one report
worst case ≈ 256 MB × 3 nodes × 5 participants × 40 pooled backends ≈ 153 GB
observed : OOM killer terminating backends under peak load
-- AFTER: default restored to 16MB, ALTER ROLE analytics SET work_mem = '256MB'
worst case ≈ 256 MB × 3 × 5 × 4 reporting backends ≈ 15 GB
+ 16 MB × 3 × 36 OLTP backends ≈ 1.7 GB
report performance: unchanged
The report kept its memory. The other thirty-six backends stopped being entitled to it.
Common Pitfalls #
Treating work_mem as per query. Each memory node claims it independently. Diagnostic signal: a plan with several Sort and Hash nodes each reporting large Memory Usage. Fix: count nodes before choosing a value.
Forgetting parallel workers. Each participant gets its own allowance. Diagnostic signal: memory use tracking Workers Launched. Fix: multiply by participants, and cap max_parallel_workers_per_gather on memory-constrained servers.
Sizing against client connections rather than server connections. Behind a transaction-mode pooler only the server-side pool can hold allowances. Diagnostic signal: a budget built from max_client_conn. Fix: budget from default_pool_size.
Using plain SET behind a pooler. The value leaks to whoever borrows the connection next. Diagnostic signal: OLTP backends with an unexpected work_mem in pg_settings sourced from session. Fix: SET LOCAL, or role-level defaults.
Budgeting only for the steady state. Peak concurrency is what exhausts memory, and it arrives with a retry storm or a scheduled report overlapping the daily traffic peak. Diagnostic signal: an incident timeline showing memory exhaustion at a predictable hour. Fix: size the worst case against peak concurrency, not average, and cap the reporting pool separately from the application pool.
Ignoring the per-backend baseline. Beyond work_mem, each backend holds catalog caches, plan caches, and its own temporary buffers, which on a long-lived pooled connection grows to tens of megabytes. Diagnostic signal: resident memory per backend far above the allowance arithmetic. Fix: include a per-backend baseline in the budget, and recycle pooled server connections periodically.
Assuming temp_file_limit protects memory. It caps temporary file usage, which is what a spill writes to disk — it does not limit the in-memory allowance at all. Diagnostic signal: a limit set in the belief it bounds RAM. Fix: bound memory with work_mem and concurrency, and use temp_file_limit for what it does, which is stopping one runaway query from filling the disk.
Frequently Asked Questions #
Is work_mem per query or per operation?
Per operation. Every sort, hash join, hash aggregate, and bitmap build in a plan may hold its own allowance simultaneously, and hash-based nodes get work_mem × hash_mem_multiplier on top.
How do parallel workers change it? Each participating worker runs its own copy of the memory-hungry nodes with its own allowance, so a plan with two hash nodes and four workers plus the leader can hold ten allowances at once.
Global or per role? Per role or per transaction. A global increase entitles every pooled backend to the same allowance, multiplying worst-case memory by the pool size, while a role-scoped value confines the cost to the workload that benefits.
Related #
- Connection Pooling & Plan Cache — parent guide: how pooling reshapes the runtime
- Session Reset and DISCARD ALL Effects — sibling: what a pooler resets between clients
- Runtime Environment — section overview: the environment around the executor
- Tuning work_mem for Hash Aggregates — sizing the allowance for a single node