PostgreSQLXI · Memory and Resource ManagementMemory
The work_mem trap
What you'll learn
- Decide whether a spill is actually costing anything on your storage
- Scope work_mem per role or per session rather than cluster-wide
- Read Sort Method and Batches to identify what spilled and why
- Explain hash_mem_multiplier and when it is the right setting to change
Prerequisites
Practice
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
work_mem is the setting most often raised on the strength of advice
rather than measurement, and the advice contains two claims that deserve
testing: that spilling to disk is very expensive, and that the risk of
raising it is manageable by arithmetic.
Testing the first claim
A sort of 2,000,000 rows from a 215 MB table. Three consecutive runs at each setting, warm cache, on local NVMe.
$ psql -U postgres -c "EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON) SELECT count(*) FROM (SELECT * FROM ordersx ORDER BY amount, created_at) s" Sort Method: external merge Disk: 50896kB Execution Time: 1142.022 ms
Sort Method: external merge Disk: 50896kB Execution Time: 1142.370 ms
Sort Method: external merge Disk: 50896kB Execution Time: 1147.128 ms$ psql -U postgres -c "SET work_mem='1GB'; EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY ON) SELECT count(*) FROM (SELECT * FROM ordersx ORDER BY amount, created_at) s" Sort Method: quicksort Memory: 111653kB Execution Time: 1166.916 ms
Sort Method: quicksort Memory: 111653kB Execution Time: 1168.633 ms
Sort Method: quicksort Memory: 111653kB Execution Time: 1182.044 msThe in-memory sort was slower. 1142–1147 ms spilling, 1167–1182 ms in memory, consistently across three runs each.
Two further readings from the same capture.
The in-memory sort needed 111,653 kB to sort data that occupied
50,896 kB on disk. More than twice as much, because the in-memory tuple
representation carries overhead the on-disk sort format does not. Sizing
work_mem from the Disk: figure of a spill will under-provision by
roughly that factor.
The spill was visible in the plan and in the log:
Buffers: shared hit=570 read=16103, temp read=12723 written=12751
LOG: temporary file: path "base/pgsql_tmp/pgsql_tmp2684.0", size 52117504
Testing the second claim
The arithmetic usually offered is max_connections × work_mem. Lesson
XI-01 showed why it understates: work_mem is granted per
memory-consuming node per participant, and one plan can contain
several such nodes running across a leader and its workers.
There is no formula that gives a safe cluster-wide value, because the multiplier depends on plans you cannot enumerate.
Reading what spilled
| Plan line | Means |
|---|---|
Sort Method: quicksort Memory: N | Fitted in memory |
Sort Method: top-N heapsort Memory: N | A LIMIT allowed only the top N to be kept — cheap, no spill |
Sort Method: external merge Disk: N | Spilled |
Batches: 1 Memory Usage: N | A hash node that fitted |
Batches: 5 … Disk Usage: N | A hash node that spilled into five passes |
Buffers: … temp read=N written=N | Temporary file I/O, in 8 kB blocks |
Turn on the logging so spills are recorded without anyone having to be watching:
ALTER SYSTEM SET log_temp_files = 0; -- log every temporary file
SELECT pg_reload_conf();
The default is -1, meaning never. Zero logs all of them, and on a
healthy system that is a small number of lines. Setting it to a size
threshold such as '10MB' is a reasonable compromise on a busy cluster.
hash_mem_multiplier
Hash nodes — hash joins and hash aggregates — get
work_mem × hash_mem_multiplier rather than plain work_mem. The
default is 2.0 in PostgreSQL 18.
$ psql -U postgres -At -c "SET work_mem='16MB'; SET hash_mem_multiplier=N; SET enable_sort=off; EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) SELECT customer_id, count(*) FROM ordersx GROUP BY customer_id" hash_mem_multiplier=1.0 : Batches: 5 Memory Usage: 16441kB Disk Usage: 1568kB
hash_mem_multiplier=2.0 : Batches: 1 Memory Usage: 16409kB
hash_mem_multiplier=8.0 : Batches: 1 Memory Usage: 16409kBAt 1.0 the aggregate spilled into five batches. At the 18 default of 2.0 it did not. Raising it further changed nothing, because it already fit.
Why the separate multiplier exists: hash nodes benefit disproportionately from extra memory, because a hash table that fits avoids re-reading its input entirely, whereas a sort that spills still does the same comparisons and merely writes and re-reads runs. Giving hashes more without giving sorts more is a better trade than raising both.
If a workload’s spills are all hash nodes, raise
hash_mem_multiplier rather than work_mem — it is the more targeted
change.
What to take from this
- Measured: a 250-fold
work_memincrease made a large sort slightly slower on warm NVMe. Test the claim on your own storage. - An in-memory sort needed 2.2× the memory of the corresponding disk
spill. Do not size from the
Disk:figure. - No formula gives a safe cluster-wide value. Scope per role, or per transaction.
log_temp_files = 0records spills without anyone watching. The default is never.hash_mem_multiplieris the targeted setting when the spills are hash nodes; the 18 default of 2.0 prevented a spill that 1.0 caused.- Hash aggregates spill since 13. Before that they grew until the server died.
Cross-course references
- Linux for Production Sysadmins — Part XL (Memory Performance) covers what actually happens as a host approaches its memory limit, which is the failure this setting can cause.
- Observability for Production Sysadmins — Part LIX (Database observability) covers exporting temporary-file bytes, which is the measurement that should drive this setting rather than a guess.
Quiz
Knowledge check · 6 questions
Q1. A plan reports 'Sort Method: external merge Disk: 50896kB'. How much work_mem would be needed for this sort to run in memory?
Q2. A team raises work_mem cluster-wide from 4 MB to 256 MB with max_connections at 200, reasoning that not all connections are active at once. What is the flaw?
Q3. After upgrading from PostgreSQL 12 to a later version, a reporting query that always worked has become noticeably slower and now writes temporary files. What changed?
Q4. Which plan or log signals indicate that a node spilled to disk? Select all that apply.
Q5. Because hash nodes benefit disproportionately from extra memory, raising hash_mem_multiplier is a more targeted change than raising work_mem when the spills are hash joins and aggregates.
Q6. How would you decide whether raising work_mem is worth doing on a particular cluster?
Passing score: 75%. Answers are checked in this browser.