Skip to main content
RunBook Academy

← All break/fix scenarios in PostgreSQL

intermediatepg-temp-files~40 min

Raising work_mem to stop queries spilling to disk made the reports slower and nearly filled the volume

Reported symptoms

  • The data volume reaches 94 percent every weekday between 12:00 and 13:00 and recovers by 13:30
  • pgsql_tmp under the data directory grows to 180 GB during that hour
  • The reporting queries that run in that window take 40 to 90 seconds each and the business considers them slow
  • work_mem was raised from 4 MB to 256 MB to stop the spilling, cluster-wide, at the start of the quarter
  • After the change the reports got slower rather than faster, and the host began showing memory pressure at lunchtime
  • One report that previously completed now occasionally fails, and the server log records a backend terminated by signal 9
  • The temp files did shrink, so the change is regarded as partially successful

Evidence

  • · log_temp_files = 0 records lines of the form temporary file: path base/pgsql_tmp/pgsql_tmpNNNN.0, size 189300736
  • · pg_stat_database for the reporting database shows temp_files 7 and temp_bytes 1109 MB for a single measured query set
  • · EXPLAIN ANALYZE at work_mem = 4MB reports Sort Method: external merge Disk: 184864kB with an execution time of 1295.160 ms
  • · The same sort at work_mem = 256MB reports Sort Method: quicksort Memory: 252269kB with an execution time of 1688.832 ms
  • · A hash aggregate over two million groups took 674.178 ms at 4MB, 654.554 ms at 64MB and 1472.613 ms at 1GB
  • · The lunchtime window runs 60 concurrent reporting sessions
  • · The slowest single query plan contains four separate memory-consuming nodes - two sorts and two hash joins
  • · The host has 64 GB of RAM and shared_buffers is 16 GB
Diagnosis and resolutionclick to reveal

Root cause

Two mistakes, and the second one was made in response to a misreading of the first. **`work_mem` is not a per-server budget.** It is an allowance granted to each memory-consuming node, in each query, in each session, concurrently. The slowest plan in this workload contains four such nodes. With 60 concurrent reporting sessions, `work_mem = 256MB` describes a worst case of 60 x 4 x 256 MB — around 60 GB — on a 64 GB host that has already committed 16 GB to `shared_buffers`. That worst case does not arrive every day, which is why the change survived a quarter. When it does arrive, the operating system reclaims memory the only way it can, and `terminated by signal 9` in the server log is the out-of-memory killer taking a backend. **The spilling was not the bottleneck.** This is the part worth sitting with, because it contradicts the intuition that drove the change. Measured on the same data, at four settings: | `work_mem` | Method | Time | | --- | --- | --- | | 4 MB | external merge, 184864 kB on disk | 1295 ms | | 64 MB | external merge, 184800 kB on disk | 1680 ms | | 256 MB | quicksort, 252269 kB in memory | 1689 ms | | 512 MB | quicksort, 252269 kB in memory | 1675 ms | The on-disk external merge was **faster** than the in-memory quicksort. Temporary files are written through the operating system page cache; an external merge of two million rows is not obviously worse than one large quicksort, and here it was better. A hash aggregate over two million groups behaved the same way: 674 ms at 4 MB, 655 ms at 64 MB, and 1473 ms at 1 GB — more than twice as slow with the largest allowance, because the plan changed to one that held 762 MB and was worse. So the reports got slower for a reason that has nothing to do with memory pressure: a larger `work_mem` changes which plans the planner considers cheap, and the plans it then chooses are not always faster. The 180 GB of temporary files is a real capacity problem and deserves its own answer. It was not, however, evidence that the queries were slow because they were spilling.

Remediation

Return `work_mem` to a value the host can survive at full concurrency, and set it where it belongs rather than cluster-wide: ```sql -- cluster default: sized for ordinary sessions ALTER SYSTEM SET work_mem = '8MB'; SELECT pg_reload_conf(); -- the reporting role, deliberately, with the arithmetic written down ALTER ROLE reporting SET work_mem = '64MB'; ``` The arithmetic to write down is `concurrent_sessions x memory_nodes_per_plan x work_mem`, compared against RAM minus `shared_buffers` minus the operating system's needs. If nobody can state that number, `work_mem` has not been chosen — it has been guessed. For a single heavy query, set it in the session rather than for everyone: ```sql SET LOCAL work_mem = '512MB'; ``` `SET LOCAL` inside a transaction reverts at commit, which is what makes this safe to put in a report's own code. Measure each query at several settings before deciding anything. `EXPLAIN (ANALYZE, BUFFERS)` reports both the method and the memory or disk used: ```sql SET work_mem = '4MB'; EXPLAIN (ANALYZE, BUFFERS) SELECT ...; SET work_mem = '64MB'; EXPLAIN (ANALYZE, BUFFERS) SELECT ...; SET work_mem = '256MB'; EXPLAIN (ANALYZE, BUFFERS) SELECT ...; ``` Read `Sort Method`, `Batches` and `Memory Usage`. A hash join that reports `Batches: 1` is not spilling; one reporting many batches is, and that is where extra memory most often pays. A sort reporting `external merge` may be perfectly fine, as it was here. Address the disk separately, because it is a separate problem: - Set `temp_file_limit` per role so one runaway query cannot fill the volume: ```sql ALTER ROLE reporting SET temp_file_limit = '20GB'; ``` - Give temporary files their own space with `temp_tablespaces`, so filling them cannot stop the cluster. A full data volume is a `PANIC`; a full temp tablespace is a failed query. Keep `log_temp_files` enabled so you can see which queries produce the files rather than only that files exist.

Verification

`pgsql_tmp` no longer approaches the volume's capacity at lunchtime, and the volume does not exceed a threshold you have chosen with hours of headroom rather than a percentage. No backend is terminated by signal 9. Check the server log explicitly rather than waiting for a report to fail: ```bash grep -E 'terminated by signal 9|out of memory' /var/log/postgresql/postgresql-18-main.log ``` Report durations are measured before and after, per query, not as an average. The claim being verified is that each individual report is at least as fast as it was — which is not what happened last quarter. `pg_stat_database` shows temp usage consistent with what you intended: ```sql SELECT datname, temp_files, temp_bytes, pg_size_pretty(temp_bytes) AS temp_pretty FROM pg_stat_database WHERE datname = 'reporting'; ``` Host memory has headroom at peak concurrency. Measure during the lunchtime window, not at 09:00. `temp_file_limit` actually fires when exceeded. Test it once with a deliberately oversized query and confirm the query fails rather than the volume filling.

Prevention

**Write the `work_mem` arithmetic down.** `concurrent_sessions x memory_nodes_per_plan x work_mem` against available RAM. A `work_mem` that nobody can defend with that calculation is a latent out-of-memory incident. **Set `work_mem` per role, not cluster-wide.** The reporting workload and the OLTP workload have different needs and different concurrency; one number cannot serve both. **Do not treat spilling as a defect.** Measured here, the external merge sort was faster than the in-memory quicksort, and a hash aggregate was twice as slow with a gigabyte as with four megabytes. Spilling is a fact to observe, not a fault to eliminate. **Measure before tuning, and measure the query rather than the counter.** The `temp_bytes` counter says files were written. It says nothing about whether writing them cost anything. **Set `temp_file_limit` per role.** It converts "the volume filled and the cluster PANICked" into "one query failed", which is a far better outcome and needs no operator. **Give temporary files their own tablespace** so they cannot take the cluster down. **Keep `log_temp_files` on**, at a threshold that records the ones that matter, so you can attribute files to queries. **Alert on `pgsql_tmp` size and on `temp_bytes` growth**, separately from the data-volume alert. They are a workload signal, and they move before the volume does. **Remember that a larger `work_mem` changes plans.** More memory does not simply make the same plan faster; it makes different plans look cheap, and some of them are not.

Reported symptoms

The data volume reaches 94 percent every weekday between 12:00 and 13:00 and recovers by 13:30. pgsql_tmp grows to 180 GB during that hour.

The reporting queries in that window take 40 to 90 seconds each and the business considers them slow.

work_mem was raised from 4 MB to 256 MB cluster-wide at the start of the quarter, to stop the spilling.

After the change the reports got slower, and the host began showing memory pressure at lunchtime. One report now occasionally fails, and the server log records a backend terminated by signal 9.

The temp files did shrink, so the change is regarded as partially successful.

Evidence provided

Read-only / Safewhat log_temp_files records
$ grep 'temporary file' /var/log/postgresql/postgresql-18-main.log | tail -3
2026-08-28 01:24:49.947 UTC [12052] postgres@lab14 LOG:  temporary file: path "base/pgsql_tmp/pgsql_tmp12052.0", size 189300736
2026-08-28 01:24:51.667 UTC [12059] postgres@lab14 LOG:  temporary file: path "base/pgsql_tmp/pgsql_tmp12059.1", size 188845868
2026-08-28 01:24:51.679 UTC [12059] postgres@lab14 LOG:  temporary file: path "base/pgsql_tmp/pgsql_tmp12059.0", size 189235200

And the measurement that the change was based on — the same sort, the same data, four settings:

Read-only / Safea two-million-row sort at four work_mem settings
$ SET work_mem = '...'; EXPLAIN (ANALYZE) SELECT ... ORDER BY ...;
  work_mem = 4MB:
             Sort Method: external merge  Disk: 184864kB
   Execution Time: 1295.160 ms
work_mem = 64MB:
             Sort Method: external merge  Disk: 184800kB
   Execution Time: 1679.936 ms
work_mem = 256MB:
             Sort Method: quicksort  Memory: 252269kB
   Execution Time: 1688.832 ms
work_mem = 512MB:
             Sort Method: quicksort  Memory: 252269kB
   Execution Time: 1675.435 ms

A hash aggregate over two million groups: 674 ms at 4 MB, 655 ms at 64 MB, 1473 ms at 1 GB.

The window runs 60 concurrent reporting sessions. The slowest plan contains four memory-consuming nodes — two sorts and two hash joins. The host has 64 GB of RAM and shared_buffers is 16 GB.

Work the evidence before reading on

  1. 60 sessions, four memory nodes each, 256 MB. What is the worst case?
  2. The sort at 4 MB was faster than the sort at 512 MB. What does that do to the premise of the change?
  3. terminated by signal 9 — who sent that signal, and why?
  4. Is the 180 GB of temp files the same problem as the slow reports?

Root cause

work_mem is per operation, not per server

The spilling was not the bottleneck

The hash aggregate is more pointed still: 674 ms at 4 MB and 1473 ms at 1 GB. More than twice as slow with a gigabyte, because the plan changed to one that held 762 MB and was worse.

The 180 GB is real, and it is a different problem

It is a genuine capacity issue and it deserves its own answer. It was never evidence that the queries were slow because they were spilling.

Resolution

Return work_mem to a value the host survives at full concurrency, and set it where it belongs:

-- cluster default: sized for ordinary sessions
ALTER SYSTEM SET work_mem = '8MB';
SELECT pg_reload_conf();

-- the reporting role, deliberately
ALTER ROLE reporting SET work_mem = '64MB';

Write down concurrent_sessions x memory_nodes_per_plan x work_mem against RAM minus shared_buffers minus the operating system’s needs. If nobody can state that number, work_mem has not been chosen — it has been guessed.

For a single heavy query, scope it to the transaction:

SET LOCAL work_mem = '512MB';

SET LOCAL reverts at commit, which is what makes it safe inside a report’s own code.

Measure each query at several settings before deciding:

SET work_mem = '4MB';   EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
SET work_mem = '64MB';  EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
SET work_mem = '256MB'; EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

Read Sort Method, Batches and Memory Usage. A hash join reporting Batches: 1 is not spilling; one reporting many batches is, and that is where extra memory most often pays. A sort reporting external merge may be entirely fine.

Then address the disk, as its own problem:

ALTER ROLE reporting SET temp_file_limit = '20GB';

And give temporary files their own space with temp_tablespaces. A full data volume is a PANIC; a full temp tablespace is a failed query.

Verification

pgsql_tmp no longer approaches capacity at lunchtime, against a threshold expressed in hours of headroom rather than a percentage.

No backend is terminated by signal 9 — checked directly, not waited for:

grep -E 'terminated by signal 9|out of memory' /var/log/postgresql/postgresql-18-main.log

Report durations measured per query, before and after. The claim being verified is that each individual report is at least as fast as it was, which is not what happened last quarter.

SELECT datname, temp_files, temp_bytes, pg_size_pretty(temp_bytes) AS temp_pretty
FROM pg_stat_database WHERE datname = 'reporting';

Host memory has headroom at peak concurrency — measured during the lunchtime window, not at 09:00.

temp_file_limit fires when exceeded. Test it once with a deliberately oversized query and confirm the query fails rather than the volume filling.

Prevention

Write the work_mem arithmetic down. A work_mem nobody can defend with that calculation is a latent out-of-memory incident.

Set work_mem per role. Reporting and OLTP have different needs and different concurrency; one number cannot serve both.

Do not treat spilling as a defect. The external merge was faster here, and the hash aggregate was twice as slow with a gigabyte as with four megabytes.

Measure the query, not the counter. temp_bytes says files were written. It says nothing about whether writing them cost anything.

Set temp_file_limit per role. It converts “the volume filled and the cluster PANICked” into “one query failed” — a far better outcome, needing no operator.

Give temporary files their own tablespace.

Keep log_temp_files on, so files can be attributed to queries.

Alert on pgsql_tmp size and temp_bytes growth, separately from the data-volume alert. They move before the volume does.