Buffers & memoryPro · lab evidenceSource-grounded1 real lab transcript

work_mem and Spills: Where Sorts and Hashes Go to Disk

work_mem is the memory budget for a single sort, hash, or similar operation — not per query and not per connection. A complex query can have many such operations running at once, each entitled to its own work_mem. With parallel workers, each worker also gets its own allotment. This multiplicative behaviour is why a seemingly modest work_mem can still exhaust RAM under load.

Grounded in the source

Where this comes from in postgres/postgres

Each citation was checked against the REL_17_STABLE branch: real file path, symbol present, exact line number. Follow any link to read the code.

src/backend/utils/sort/tuplesort.cVerified · REL_17_STABLE

Primary symbol: tuplesort_begin_common · line 645

The one thing to understand first

work_mem is the memory budget for a single sort, hash, or similar operation — not per query and not per connection. A complex query can have many such operations running at once, each entitled to its own work_mem. With parallel workers, each worker also gets its own allotment. This multiplicative behaviour is why a seemingly modest work_mem can still exhaust RAM under load.

*work_mem is charged per operation, not per query — so the real ceiling is work_mem × concurrent nodes × parallel workers.* Set it too low and sorts and hashes spill to temp files; set it too high and a burst of concurrency OOMs the server. The whole skill is "modest global, generous local."

Sorts: quicksort vs external merge

The sort code (src/backend/utils/sort/tuplesort.c) keeps tuples in memory and quicksorts them if they fit in work_mem. If they do not, it switches to an external merge sort: it writes sorted runs to temporary files and merges them. EXPLAIN ANALYZE shows the difference directly:

Reference
SQL
-- In memory:
Sort Method: quicksort  Memory: 5421kB
-- Spilled to disk:
Sort Method: external merge  Disk: 102400kB

The moment you see external merge, the sort is doing disk I/O that more work_mem could eliminate.

Hash joins: build side must fit

A hash join builds a hash table on the smaller input. If that table exceeds work_mem, the executor switches to a batched (multi-pass) hash join, partitioning both inputs to temp files and processing batches. EXPLAIN ANALYZE shows Batches: 8 (more than 1 means spilling) and the peak memory used. A misestimated build side is the usual cause — the planner expected it to fit and it did not.

Hash aggregates can spill too

Modern PostgreSQL lets HashAggregate spill to disk when the grouping hash table exceeds work_mem (older versions would blow past the limit instead). You will see this on high-cardinality GROUP BY. If a hash aggregate spills heavily, either raise work_mem for that workload or reduce group cardinality.

Sizing it safely

A rough guard rail:

Reference
SQL
work_mem ≈ (RAM available for queries)
          / (max_connections * avg sort/hash nodes per query)

Because the global default applies to every operation in every session, keep the cluster-wide value conservative and raise it locally where it matters:

Lab-verified
SQL
-- Per-session for an analytics job
SET work_mem = '256MB';

-- Per-role for a reporting user
ALTER ROLE analytics SET work_mem = '256MB';
Real captured output for this query is in the Pro lab notebook below.

Layer 3 — Watch it happen on your own database

Lab-verified
SQL
-- Make spills visible in the logs and in the plan
SET log_temp_files = 0;          -- log every temp file with its size

EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, count(*) FROM orders GROUP BY customer_id ORDER BY 2 DESC;
--   Sort Method: external merge  Disk: 102400kB   <- spilling
--   HashAggregate ... Batches: 8  ...             <- spilling

SET work_mem = '256MB';          -- session-only: re-run and watch it fit
-- Cluster-wide spill volume:
SELECT datname, temp_files, pg_size_pretty(temp_bytes) FROM pg_stat_database;
Real captured output for this query is in the Pro lab notebook below.

Run the query, look for Sort Method: external merge or a hash join/aggregate with Batches > 1 — that is the operation doing disk I/O. Raise work_mem for the session and re-run: when the method flips to quicksort / Batches: 1, you have found the right size for that workload. pg_stat_database.temp_bytes shows the cluster-wide spill bill over time.

Layer 4 — The levers this hands you

Too low and queries grind on temp-file I/O; too high and concurrent queries OOM the server. The right approach is a modest global value plus targeted overrides:

  • Conservative global default, sized roughly as RAM-for-queries / (max_connections × avg sort/hash nodes per query).
  • Per-session or per-role overrides for known heavy work: SET work_mem = '256MB' or ALTER ROLE analytics SET work_mem = '256MB'.
  • **hash_mem_multiplier** gives hash-based nodes more headroom than sorts without raising both.
  • **log_temp_files = 0** turns spilling queries into log lines — the fastest way to find them in production.

Layer 5 — What an Oracle DBA should expect vs what they get

This is one of the biggest mental shifts for an Oracle DBA:

  • **No pga_aggregate_target.* Oracle gives the instance a single global PGA budget and divides it automatically. PostgreSQL has no* global work-memory cap — work_mem is per operation, and the total is your responsibility to bound via connection limits and sane defaults.
  • Spills go to temp files, like Oracle's TEMP tablespace, but you watch them through log_temp_files and pg_stat_database.temp_bytes rather than V$TEMPSEG_USAGE.
  • Connection count is the multiplier. Because Oracle pools sessions (shared server) or manages PGA centrally, raw connection count matters less. In Postgres, process-per-connection plus per-node work_mem means a pooler (PgBouncer) is part of memory safety, not just throughput.
  • "Automatic" memory management is manual here. There is no WORKAREA_SIZE_POLICY=AUTO; you size work_mem deliberately and override locally.

Key takeaway

work_mem caps a single sort/hash/aggregate; exceed it and tuplesort.c switches to external merge sort, hash joins go multi-batch, and hash aggregates spill — all to temp files. Diagnose with EXPLAIN ANALYZE (look for external merge and Batches > 1) and log_temp_files. Keep the global value modest because it is charged per operation across every concurrent session, and raise it locally for the few heavy queries that need it.

Interview lens

How this shows up in interviews

What an interviewer is actually testing, the same mechanism seen from three roles, and the gap between a shallow answer and one that shows real depth.

What the interviewer is testing

Whether you know work_mem is per-operation (not per-query or per-connection), the multiplicative blow-up risk, and what happens on a spill.

Questions you should be able to answer

  • Is work_mem per query or per operation, and why does the distinction matter?
  • What happens when a sort or hash exceeds work_mem?
  • How can a modest work_mem still exhaust a server's RAM?

Database engineer

You budget work_mem x concurrent operations x parallel workers, and set it per-role or per-statement instead of globally high.

Software engineer

You spot spills in EXPLAIN ANALYZE ('Sort Method: external merge Disk') and fix them with an index or fewer rows, not by blindly raising work_mem.

Feature builder

You paginate and bound heavy sorts/aggregations so one report can't demand gigabytes of sort memory.

Shallow answer

'work_mem is how much memory a query can use.' Wrong granularity — it misses the per-operation, per-worker multiplication.

Answer that shows depth

work_mem is the budget for a single sort, hash, or similar operation — not the whole query and not the connection. One query can contain many such nodes, each entitled to its own work_mem, and each parallel worker gets its own copy, so peak usage is roughly work_mem x operations x workers — which is how a 'small' value OOMs under concurrency. When an operation exceeds its budget it spills to temp files (external merge sort, batched hash join), visible in EXPLAIN ANALYZE; the real fix is usually fewer rows or a better plan, not raising the global setting.

Pro lab notebook

The captured run behind every query

Every SQL query above is real and the source citations are verified. Pro unlocks the captured psql output — each query re-run against a real PostgreSQL 17.10 lab, with its verbatim result.

Pro lab notebookTested, not just explained

1 real lab transcript for this lesson

The explanation and the SQL above are free — so are the verified source citations. What Pro unlocks is the proof: every query in work_mem and Spills: Where Sorts and Hashes Go to Disk re-run against a real PostgreSQL 17.10 instance, with its verbatim psql output captured line for line.

What Pro unlocks here

  • Every query in this lesson re-run against a real PostgreSQL 17.10 lab
  • The verbatim psql output — page headers, row versions, plan nodes, catalog rows
  • The corrected form of any query the source lesson got wrong, re-captured
  • A copy-ready notebook you can replay yourself to confirm every claim
Unlock with Pro — $24.99/moor $199/yr — save ~$100

How this was verified

This explanation is anchored to PostgreSQL's source tree — the files linked above, on the REL_17_STABLE branch — and cross-checked against the official documentation for versions 15 through 18. Every SQL query shown was executed and its output captured in a throwaway PostgreSQL 17.10 lab; where a query needed correcting to run, that correction is noted inline.

Official documentation: §20.4.1 Memory (work_mem)