Query icon
If icon
Fail icon
OutputValues icon
Log icon
SlackIncomingWebhook icon
Pause icon
Loop icon
Schedule icon

PostgreSQL Temp Spill work_mem Tuning Guard

Find PostgreSQL roles spilling sorts to disk with no work_mem override, size a value from the measured spill, set it with ALTER ROLE and verify.

Categories
DataInfrastructure

work_mem is the memory one sort or one hash node may use before it spills to disk. The default is 4MB. It is charged per node rather than per query, so a reporting role running wide sorts and hash joins spills on almost every statement. PostgreSQL writes temp files under pgsql_tmp, the query slows by an order of magnitude, nothing errors, nothing is logged by default. The only trace is temp_blks_written in pg_stat_statements plus temp_files and temp_bytes in pg_stat_database. The analyst just reports that the dashboard is slow.

This blueprint reads temp_blks_written per role and per database from pg_stat_statements, keeps the roles that have no work_mem override of their own in pg_db_role_setting, computes a value from the worst single call they measured, caps it against a host memory budget, then behind a dry run and a Pause applies it with ALTER ROLE ... SET work_mem and re-reads pg_db_role_setting to prove the override is stored.

This blueprint was created by zkasuran.

Findings

Finding Severity Meaning
TEMP_SPILL_NO_WORK_MEM_OVERRIDE High or Medium The role spills past the reporting threshold with no work_mem setting of its own. This is the only finding that gets a statement
SPILL_WITH_OVERRIDE_PRESENT Info The role spills but already carries its own work_mem. Somebody tuned it, so the flow leaves it alone
SPILL_ALREADY_SIZED Info The role spills, but the value it already gets covers the measured spill. A budget cap sitting below that value has the same effect, so a change would not help
SPILL_BY_SUPERUSER_SKIPPED Info A superuser spilled. Superusers are never altered
SPILL_ROLE_EXCLUDED Info The role is in excluded_roles

The effective value is resolved the way PostgreSQL resolves it at login: ALTER ROLE ... IN DATABASE, then ALTER ROLE, then ALTER DATABASE, then the server setting.

Safety

work_mem is charged per sort or hash node and per connection, so a value times many concurrent sessions is what exhausts host memory. That is the real risk here, which is why the write sits behind a Pause and why the proposal is bounded twice.

  • The proposal comes from the worst single call the role actually measured, not from a number somebody picked. Two forces pull against each other there. The measured bytes are the total for the whole statement, which is above what any single node inside it needed. The tuples an external merge packs onto tape are smaller than the same tuples held in memory for a quicksort, measured here at 1.7 times, which is what headroom_multiplier covers. The proposal is therefore an estimate, so a role that still spills after the change wants a higher multiplier rather than a hand picked number.
  • max_work_mem_mb is a hard ceiling.
  • A second ceiling comes from the server itself: shared_buffers is conventionally a quarter of host memory, so four times shared_buffers is used as a memory proxy, ram_fraction_for_work_mem of it is the whole allowance for sorting. max_connections then divides it. A proposal above that comes back clamped with clamped_by_budget set, so a tiny instance is never handed a large value.
  • The override is per role, not per database or per server, so the blast radius is the reporting role rather than the whole instance. OLTP roles keep the value they have.
  • Superusers, pg_* roles, template databases and excluded_roles are never touched.

What the verification proves, exactly. The second read of pg_db_role_setting proves the override is stored at the scope that was asked for, with the value that was proposed. It does not prove the spilling stopped. That depends on the queries that run next, on how many nodes each plan has, on how skewed the data is. Settings are read at connect time, so sessions the role already holds keep the old value until they reconnect. To see the payoff, open a new session as the role, check SHOW work_mem, then run the same query and compare Sort Method in EXPLAIN (ANALYZE, BUFFERS) and temp_blks_written before against after.

How it works

  1. precheck (io.kestra.plugin.jdbc.postgresql.Query, read-only) confirms pg_stat_statements is installed. precheck_gate fails with a clear message if it is not, since the spill counters live nowhere else.
  2. audit (read-only) sums temp_blks_written and calls per role and database from pg_stat_statements, takes the worst single call, resolves the effective work_mem and its source from pg_db_role_setting, computes need_kb from the worst call times headroom_multiplier, clamps it to the ceiling and the host budget, then builds one ALTER ROLE ... SET work_mem per role with format('%I') plus %L.
  3. actionable keeps only the roles with no override of their own. summary logs every row with its numbers.
  4. route (io.kestra.plugin.core.flow.If): nothing actionable logs it, otherwise alert posts the roles, their spill and the proposed value to Slack.
  5. remediate_gate runs only when auto_remediate is true and dry_run is false. In any other combination would_change logs the exact statements and stops.
  6. approval_gate (io.kestra.plugin.core.flow.Pause) holds the run. On approval, remediate_overrides (io.kestra.plugin.core.flow.Loop, concurrencyLimit: 1) runs one statement at a time, because concurrent ALTER ROLE ... SET on the same role collide in pg_db_role_setting.
  7. reverify reads pg_db_role_setting again and compares the stored value with the proposed one. verify_result fails the run through io.kestra.plugin.core.execution.Fail on any MISSING or MISMATCH.
  8. errors alerts Slack when the run fails.

What you get

  • The roles whose queries are really spilling, measured in temp bytes per role rather than guessed from a slow dashboard.
  • A value derived from the spill the role measured, with the arithmetic and both ceilings printed in the finding, so the number can be argued with before it is approved.
  • A per role change, so one bad value cannot reach the whole instance.
  • A second read of pg_db_role_setting that fails the run when the statement was rejected, which is what happens when the connecting role lacks the rights or the value is outside the range PostgreSQL accepts.
  • Spilling roles that someone already tuned, superusers and roles you exclude, all visible in the report, none of them altered.

Who it's for

  • Teams with a reporting or BI role whose dashboards got slow with no error anywhere.
  • DBAs who have found gigabytes under pgsql_tmp and want the per role fix done, bounded and proven.
  • Platform engineers who need the memory arithmetic written down rather than carried in somebody's head.

Why orchestrate this with Kestra

The measurement lives in pg_stat_statements and accumulates between resets, so it wants reading on a schedule rather than during an incident. Kestra reads it, sizes the change against the server's own memory numbers, posts the proposal to Slack, applies it only behind auto_remediate, dry_run and a human approval, one statement at a time, re-reads the catalog to prove it landed. The record of who approved what stays in Kestra.

Tested end to end

On Kestra 2.0.5 OSS against PostgreSQL 16.15 with pg_stat_statements preloaded, shared_buffers 2GB, max_connections 20, server work_mem 4MB. A local HTTP sink stood in for Slack. Seed: a 50MB table of 400000 rows, then the same unindexed ORDER BY run three times as each of four roles, so every role wrote 86.3MB of temp files over 3 calls with a worst single call of 28.8MB. reporting had no override, analytics_tuned had work_mem=8MB, migration_bot was in excluded_roles, admin_super is a superuser. The budget worked out at 104858kB per connection, so the proposal for reporting was 58MB (28.8MB times the 2.0 default) with no clamp.

Run Inputs Result
1 defaults 4 roles over the threshold, exactly 1 actionable: reporting, proposal 58MB. The other three came back SPILL_WITH_OVERRIDE_PRESENT, SPILL_ROLE_EXCLUDED and SPILL_BY_SUPERUSER_SKIPPED. pg_db_role_setting unchanged
2 auto_remediate: true, dry_run: true Same report, no statement run, still no row for reporting in pg_db_role_setting
3 auto_remediate: true, dry_run: false, resumed with approved=false Paused, then nothing changed
4 auto_remediate: true, dry_run: false, approved ALTER ROLE reporting SET work_mem = '58MB' applied. pg_db_role_setting went from no row to {work_mem=58MB}. A new session as reporting returned 58MB from SHOW work_mem, the same query's Sort Method moved from external merge Disk: 29368kB to quicksort Memory: 49379kB, so the statement's temp_blks_written stayed at 11046 across a fourth call
5 defaults, after run 4 0 actionable. reporting is now reported as SPILL_WITH_OVERRIDE_PRESENT
6 approved, inputs pushed until the proposal leaves the range PostgreSQL accepts The statement was rejected with invalid value for parameter "work_mem", reverify reported MISSING, the run FAILED, errors posted to Slack
7 approved, the role renamed during the Pause The statement hit a role that no longer existed, reverify reported MISSING, the run FAILED
8 a database with no pg_stat_statements precheck_gate failed with the install instruction instead of reading a view that is not there
9 unreachable host The errors block posted to Slack

The in-memory sort for that workload took 49379kB while the external merge had written 29368kB to tape, a ratio of 1.7, which is where the 2.0 default for headroom_multiplier comes from. At 1.5 the proposal was 44MB, the setting landed, the sort still spilled. That is the honest shape of this: the flow proves the override is stored, the operator proves the spill is gone.

Prerequisites

  • pg_stat_statements in shared_preload_libraries plus CREATE EXTENSION pg_stat_statements in the database the flow connects to. Without it there is no per role spill counter.
  • A role that can read pg_stat_statements, pg_db_role_setting and pg_settings. For the change, a superuser or a role holding ADMIN OPTION on the target role.
  • Local testing:
    • docker run -d --name pg -p 5432:5432 -e POSTGRES_PASSWORD=pg postgres:16 -c shared_preload_libraries=pg_stat_statements;
    • CREATE EXTENSION pg_stat_statements, create a login role, give it a big table with an unindexed column, then run ORDER BY on that column as the role until temp_blks_written climbs.

Secrets

  • POSTGRES_USERNAME / POSTGRES_PASSWORD: credentials used by every query.
  • SLACK_WEBHOOK_URL: used by alert and the errors block.
  • In Kestra OSS, provide each one as a base64-encoded environment variable prefixed SECRET_.

Inputs

Input Default Purpose
jdbc_url jdbc:postgresql://localhost:5432/app Database to read the counters from
excluded_roles [migration_bot] Roles whose spilling is accepted
override_scope ROLE ROLE or ROLE_IN_DATABASE
min_total_temp_mb 64 Temp megabytes a role must have written to be reported
high_severity_temp_mb 1024 Temp megabytes that make a finding HIGH
headroom_multiplier 1.5 Scale over the worst measured call
max_work_mem_mb 256 Hard ceiling for the proposal
ram_fraction_for_work_mem 0.25 Share of the memory proxy all sorting may claim
auto_remediate false Allow ALTER ROLE
dry_run true Must be false for a statement to run

Quick start

  1. Add the POSTGRES_USERNAME, POSTGRES_PASSWORD and SLACK_WEBHOOK_URL secrets.
  2. Import the flow, set jdbc_url, then list in excluded_roles anything you are happy to leave on disk.
  3. Run it once with the defaults and read the proposal, the ceiling and the budget in the findings.
  4. Agree the value with whoever owns the host memory, then run with auto_remediate: true and dry_run: false and answer the Pause.
  5. Reconnect as the role, check SHOW work_mem, re-run the slow query and compare Sort Method in EXPLAIN (ANALYZE, BUFFERS).
  6. Enable the daily trigger, which always runs in safe mode.

How to extend

  • Call pg_stat_statements_reset() on a schedule so the counters describe a window rather than all time. Keep the window bounds in a KV pair.
  • Read temp_files and temp_bytes from pg_stat_database as a second signal, which also catches spilling from statements that have aged out of pg_stat_statements.
  • Tune hash_mem_multiplier alongside work_mem for hash heavy plans.
  • Add a per role map of agreed values. Let the computed proposal only lower them.
  • Set maintenance_work_mem the same way for roles that build indexes.

Links

See How

New to Kestra?

Use blueprints to kickstart your first workflows.