Skip to main content
RunBook Academy

← All break/fix scenarios in PostgreSQL

advancedpg-long-transaction~40 min

Autovacuum ran hundreds of times and removed nothing, on tables the nightly report has never touched

Reported symptoms

  • Disk usage on the primary grows by 40 GB every night between 22:00 and 04:00 and only partially recovers during the day
  • Query latency on the busiest OLTP tables degrades through the night and improves each morning
  • Autovacuum is running constantly - pg_stat_user_tables shows hundreds of autovacuum runs across the night
  • Those runs report tuples: 0 removed and a large number that are dead but not yet removable
  • The affected tables include several the analytics workload has never read
  • An engineer increased autovacuum_max_workers from 3 to 8 and lowered the scale factors, which increased the number of runs and changed nothing else
  • The problem stops the moment the nightly reporting job finishes

Evidence

  • · Autovacuum log lines across the night read tuples: 0 removed, 4821992 remain, 3910442 are dead but not yet removable
  • · The same lines report removable cutoff which was several million XIDs old when operation ended
  • · pg_stat_activity shows one session in state active with xact_start at 22:04 and backend_xmin unchanged since then
  • · That session is a reporting job connected as analytics, running a sequence of statements inside one explicit BEGIN
  • · The job opens a transaction, runs 60 to 90 queries across six hours, and commits at the end
  • · age(backend_xmin) for that session reaches 14 million by 04:00
  • · pg_stat_user_tables shows n_dead_tup climbing on tables in unrelated schemas throughout the same window
  • · The job reads from a reporting schema only, and takes no locks on the OLTP tables
Diagnosis and resolutionclick to reveal

Root cause

A single long-running transaction holds a snapshot, and a snapshot is cluster-scoped in its effect on vacuum. Vacuum may only remove a dead row version once no transaction can still need to see it. The reporting job began at 22:04 and its snapshot is entitled to the state of the entire database as it stood at 22:04 — not only the reporting schema it happens to read. It does not matter that the job never touches the OLTP tables and takes no locks on them. Its `backend_xmin` sets a floor, and every table in the cluster is vacuumed against that floor. So autovacuum works perfectly and removes nothing. Its log lines say so with unusual precision: `tuples: 0 removed, 4821992 remain, 3910442 are dead but not yet removable`. Those rows are dead, vacuum found them, and vacuum is not permitted to reclaim them. The phrase to search for is **"not yet removable"**; it appears whenever this is happening and it appears in no other situation. Raising `autovacuum_max_workers` and lowering the scale factors made autovacuum run more often. Each run scanned more pages, wrote more WAL, consumed more I/O, and removed nothing, because the constraint was never the rate of vacuuming. The 40 GB is dead row versions plus the index entries pointing at them. It partially recovers in the morning because the snapshot is finally released at 04:00 and the next vacuum pass can do its work — but the files do not shrink, so what recovers is reusable space inside them, not disk. The distinction from an abandoned `idle in transaction` session matters here. This session is `active`. It is doing real work the business asked for. Nothing is stuck, nothing is leaked, and terminating it destroys six hours of report. The fault is the transaction's **shape**, not its existence.

Remediation

Identify the horizon holder first, and check whether it is doing work before you consider ending it: ```sql SELECT pid, usename, application_name, state, now() - xact_start AS xact_age, age(backend_xmin) AS xmin_age, left(query, 80) AS current_query FROM pg_stat_activity WHERE backend_xmin IS NOT NULL ORDER BY age(backend_xmin) DESC; ``` A session in `active` with a moving `query` is running a workload. A session in `idle in transaction` is holding a snapshot for no reason and is a different incident. If the report can be sacrificed, `pg_cancel_backend(pid)` ends the current statement and usually aborts the transaction, which releases the snapshot immediately. Prefer `pg_cancel_backend` to `pg_terminate_backend`; it is the gentler of the two and the connection survives: ```sql SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE pid = 12345; ``` Confirm the horizon actually moved, then let autovacuum work: ```sql SELECT max(age(backend_xmin)) FROM pg_stat_activity; VACUUM (VERBOSE) orders; -- expect a non-zero "removed" this time ``` The durable fix is at the job, not at the database. Split the report so that each query runs in its own transaction. Six hours of work in sixty transactions holds a snapshot for the length of one query rather than the length of the job: ```sql -- instead of: BEGIN; ...60 queries over six hours...; COMMIT; -- run each query on its own, or commit between logical sections ``` If the report genuinely requires one consistent snapshot across all sixty queries — which is worth challenging, because most reports do not — then move it off the primary. A physical standby with `hot_standby_feedback = off` will cancel the query rather than hold the primary's horizon, and a standby with feedback `on` moves the problem back to the primary, so choose deliberately: ```sql -- on the standby SHOW hot_standby_feedback; SHOW max_standby_streaming_delay; ``` Do not raise `autovacuum_max_workers` or lower the scale factors in response to this. They increase work and cannot change the outcome.

Verification

Autovacuum log lines show a non-zero `removed` count, and the `not yet removable` figure falls to near zero: ```text tuples: 15000 removed, 100000 remain, 0 are dead but not yet removable ``` That line, from a healthy vacuum, is what you are aiming at. The `0 are dead but not yet removable` at the end is the whole verification. `max(age(backend_xmin))` across `pg_stat_activity` stays small through the night: ```sql SELECT max(age(backend_xmin)) AS oldest_snapshot FROM pg_stat_activity; ``` `n_dead_tup` on the OLTP tables stops climbing through the reporting window. Disk usage stops its nightly 40 GB excursion. Note that existing bloat does not disappear on its own — the files are already large and vacuum makes the space reusable rather than returning it. Measure with `pgstattuple` and decide separately whether a rewrite is warranted. Run the report and watch `age(backend_xmin)` for its session while it runs. Under the fix it should rise and reset repeatedly rather than climbing monotonically for six hours. That is the direct test that the job's shape actually changed.

Prevention

**Alert on the oldest snapshot in the cluster, not on session duration.** A long-lived connection is fine. A long-lived *snapshot* is what stops vacuum: ```sql SELECT max(age(backend_xmin)) FROM pg_stat_activity; ``` Alert well below the point where bloat becomes expensive — tens of millions of transactions, not billions. **Search autovacuum logs for "not yet removable".** It is a precise, unambiguous signal and it appears in no other circumstance. A single alert on that phrase would have identified this on the first night. **Set `transaction_timeout` or `idle_in_transaction_session_timeout` per role.** PostgreSQL 17 added `transaction_timeout`, which bounds the whole transaction rather than a single statement or an idle period — exactly the control this incident needed: ```sql ALTER ROLE analytics SET transaction_timeout = '15min'; ``` Set it on the roles that should never hold a long transaction, and deliberately not on the ones that must. **Write long reports as many short transactions.** A report that needs a single snapshot across six hours is asserting a consistency requirement almost no report actually has. Make somebody state that requirement out loud before accepting it. **Run analytics on a standby, and decide `hot_standby_feedback` on purpose.** With it `off`, the standby cancels conflicting queries and the primary is protected. With it `on`, the standby's queries hold the primary's horizon and you have moved the workload without moving the problem. **Do not treat this as an autovacuum tuning problem.** More workers and lower thresholds increase I/O and cannot change the result. The lever is the snapshot.

Reported symptoms

Disk usage on the primary grows by 40 GB every night between 22:00 and 04:00, and only partially recovers during the day.

Query latency on the busiest OLTP tables degrades through the night and improves each morning.

Autovacuum is running constantly — hundreds of runs across the night. The runs report tuples: 0 removed and a large number dead but not yet removable.

The affected tables include several the analytics workload has never read.

An engineer raised autovacuum_max_workers from 3 to 8 and lowered the scale factors. This increased the number of runs and changed nothing else.

The problem stops the moment the nightly reporting job finishes.

Evidence provided

Read-only / Safeautovacuum finding dead rows it is not allowed to remove
$ grep 'automatic vacuum' /var/log/postgresql/postgresql-18-main.log | tail -1 -A3
2026-08-28 02:41:07.203 UTC [8812] LOG:  automatic vacuum of table "prod.public.orders": index scans: 0
tuples: 0 removed, 4821992 remain, 3910442 are dead but not yet removable
removable cutoff: 41880231, which was 14002119 XIDs old when operation ended

Illustrative output

Compare that against a healthy vacuum on the same cluster:

Read-only / Safewhat a vacuum that was allowed to work looks like
$ grep 'automatic vacuum' /var/log/postgresql/postgresql-18-main.log | tail -1 -A3
2026-08-28 00:56:50.630 UTC [10589] LOG:  automatic vacuum of table "lab10.public.churn": index scans: 1
tuples: 15000 removed, 100000 remain, 0 are dead but not yet removable
removable cutoff: 838, which was 0 XIDs old when operation ended

pg_stat_activity shows one session in state active, xact_start 22:04, backend_xmin unchanged since. It is the reporting job, connected as analytics, running 60 to 90 queries inside one explicit BEGIN and committing at the end. By 04:00 its age(backend_xmin) reaches 14 million.

It reads from a reporting schema only, and takes no locks on the OLTP tables.

Work the evidence before reading on

  1. 0 removed and 3910442 not yet removable. What is the difference between those two numbers?
  2. The report never touches orders. Why is orders affected?
  3. What did raising autovacuum_max_workers change?
  4. Is this the same incident as an abandoned idle in transaction session?

Root cause

A snapshot is cluster-scoped

The tuning change made it worse

More workers and lower scale factors made autovacuum run more often. Each run scanned more pages, wrote more WAL, consumed more I/O, and removed nothing — because the constraint was never the rate of vacuuming.

The 40 GB, and why mornings only partly recover

The 40 GB is dead row versions plus the index entries pointing at them. At 04:00 the snapshot is released and the next pass reclaims them — but the files do not shrink. What recovers is reusable space inside them, not disk.

This is not an abandoned session

Resolution

Identify the horizon holder, and check whether it is working before considering ending it:

SELECT pid, usename, application_name, state,
       now() - xact_start AS xact_age,
       age(backend_xmin)  AS xmin_age,
       left(query, 80)    AS current_query
FROM pg_stat_activity
WHERE backend_xmin IS NOT NULL
ORDER BY age(backend_xmin) DESC;

If the report can be sacrificed, cancel rather than terminate — it is the gentler signal and the connection survives:

SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE pid = 12345;

Confirm the horizon moved before assuming anything:

SELECT max(age(backend_xmin)) FROM pg_stat_activity;
VACUUM (VERBOSE) orders;   -- expect a non-zero "removed" this time

The durable fix is at the job. Split the report so each query runs in its own transaction. Six hours of work in sixty transactions holds a snapshot for the length of one query, not the length of the job.

If the report genuinely needs one consistent snapshot across all sixty queries — worth challenging, because most reports do not — move it off the primary, and choose hot_standby_feedback deliberately:

-- on the standby
SHOW hot_standby_feedback;        -- on: the standby holds the PRIMARY's horizon
SHOW max_standby_streaming_delay; -- off: the standby cancels the query instead

With feedback on you have moved the workload without moving the problem.

Do not raise autovacuum_max_workers or lower the scale factors.

Verification

Autovacuum reports a non-zero removed, and not yet removable falls to zero. That trailing zero is the whole verification.

The oldest snapshot stays small through the night:

SELECT max(age(backend_xmin)) AS oldest_snapshot FROM pg_stat_activity;

n_dead_tup on the OLTP tables stops climbing during the reporting window, and the nightly 40 GB excursion stops.

Existing bloat does not disappear on its own — the files are already large and vacuum makes space reusable rather than returning it. Measure with pgstattuple and decide separately whether a rewrite is warranted.

Run the report and watch age(backend_xmin) for its session. Under the fix it rises and resets repeatedly instead of climbing monotonically for six hours. That is the direct test that the job’s shape changed.

Prevention

Alert on the oldest snapshot, not on session duration. A long-lived connection is fine; a long-lived snapshot is what stops vacuum.

Alert on the phrase “not yet removable” in autovacuum logs.

Set transaction_timeout per role. PostgreSQL 17 added it, and it bounds the whole transaction rather than a single statement or an idle period — exactly the control this incident needed:

ALTER ROLE analytics SET transaction_timeout = '15min';

Set it on roles that should never hold a long transaction, and deliberately not on the ones that must.

Write long reports as many short transactions. A report requiring a single snapshot across six hours is asserting a consistency requirement almost no report actually has. Make somebody state it out loud before accepting it.

Run analytics on a standby, with hot_standby_feedback chosen on purpose.

Do not treat this as an autovacuum tuning problem. The lever is the snapshot.