Skip to main content
RunBook Academy

PostgreSQLXI · Memory and Resource ManagementMemory

The work_mem trap

Advanced⏱ ~30 min🧪 Lab requiredpsql

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

Not yet marked complete on this device.

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.

Read-only / Safework_mem = 4MB, the default
$ 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
Read-only / Safework_mem = 1GB, a 250-fold increase
$ 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 ms

The 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 lineMeans
Sort Method: quicksort Memory: NFitted in memory
Sort Method: top-N heapsort Memory: NA LIMIT allowed only the top N to be kept — cheap, no spill
Sort Method: external merge Disk: NSpilled
Batches: 1 Memory Usage: NA hash node that fitted
Batches: 5 … Disk Usage: NA hash node that spilled into five passes
Buffers: … temp read=N written=NTemporary 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.

Read-only / Safethe multiplier deciding whether a hash aggregate spills
$ 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: 16409kB

At 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_mem increase 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 = 0 records spills without anyone watching. The default is never.
  • hash_mem_multiplier is 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

  1. Q1. A plan reports 'Sort Method: external merge Disk: 50896kB'. How much work_mem would be needed for this sort to run in memory?

  2. 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?

  3. 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?

  4. Q4. Which plan or log signals indicate that a node spilled to disk? Select all that apply.

  5. 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.

  6. 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.