Skip to content
dbexplore

PostgreSQL 14

vacuum_failsafe_age and emergency mode

For anyone who has watched an autovacuum worker crawl through a large table while the wraparound warning counts down.

Reference page, revised in place. Last updated .

Politeness at exactly the wrong moment

Vacuum is deliberately slow. It accumulates a cost as it reads and dirties pages and sleeps when that cost crosses a limit, so that maintenance does not take a production cluster’s I/O budget away from the workload. For ordinary vacuuming that is the correct behaviour and it is why the defaults are conservative.

Near transaction ID wraparound it is precisely wrong. A table whose frozen ID has fallen far enough behind is on a path that ends with the cluster refusing new transactions, and the only thing that moves it off that path is vacuum finishing. A worker that sleeps politely through a hundred million pages while the counter runs down is optimising for the wrong thing, and before PostgreSQL 14 the only remedy was an operator noticing and changing the cost settings by hand, usually at night, usually in a hurry.

PostgreSQL 14 built the remedy into vacuum. Past a configurable age it switches into a mode with no cost limit and no optional work: the delay goes to zero, index vacuuming is abandoned, and the run does the one thing that matters, which is advancing the table’s frozen ID. There are two settings, one for transaction IDs and one for multixacts, and the release notes record both under Vacuuming, as a consequence of a single entry about vacuum becoming more aggressive near wraparound.

The thing to understand about it is that it is a last resort and not a tuning knob. When it fires, dead row pointers in indexes are left behind for a later run to clean up, so the table’s bloat gets worse in exchange for the cluster staying up. That is the correct trade and it is still a trade.

What it looks like when it fires

This fixture is rigged so the failsafe triggers on a table minutes old: the wraparound horizon is pulled down to its minimum, and the session then burns enough transaction IDs to cross it. The table deliberately has no variable-length column, so there is no secondary table to vacuum alongside it and nothing else in the log to read around.

CREATE TABLE fs_probe (id integer PRIMARY KEY, batch integer, taken date, ok boolean);
INSERT INTO fs_probe
  SELECT g, g % 50, date '2026-01-01' + g % 300, g % 2 = 0
  FROM generate_series(1, 20000) AS g;
DELETE FROM fs_probe WHERE id % 3 = 0;
CREATE PROCEDURE burn(n integer) LANGUAGE plpgsql AS $$
BEGIN
  FOR i IN 1..n LOOP
    PERFORM txid_current();
    COMMIT;
  END LOOP;
END $$;
CALL burn(120000);
CREATE TABLE
INSERT 0 20000
DELETE 6666
CREATE PROCEDURE
CALL

The evidence a fleet actually sees is in the server log rather than in somebody’s psql session, so that is where this reads it from. The server is running with its log collector on, and the query below pulls the relevant lines back out of the file it wrote, with the timestamp, the process number and the database qualification trimmed off for width.

SELECT age(relfrozenxid) AS xids_behind FROM pg_class WHERE relname = 'fs_probe';
VACUUM fs_probe;
SELECT replace(regexp_replace(line, '^\d{4}-\d\d-\d\d \S+ \S+ \[\d+\] ', ''),
               current_database() || '.', '') AS server_log
FROM pg_ls_logdir() f,
     LATERAL regexp_split_to_table(pg_read_file('log/' || f.name), E'\n') AS line
WHERE line LIKE '%as a failsafe after%'
   OR line LIKE '%relfrozenxid or relminmxid%'
   OR line LIKE '%maintenance_work_mem%'
   OR line LIKE '%bypassed by failsafe%';
 xids_behind 
-------------
      120004
(1 row)

VACUUM
                                                server_log                                                 
-----------------------------------------------------------------------------------------------------------
 WARNING:  bypassing nonessential maintenance of table "public.fs_probe" as a failsafe after 0 index scans
 DETAIL:  The table's relfrozenxid or relminmxid is too far in the past.
 HINT:  Consider increasing configuration parameter "maintenance_work_mem" or "autovacuum_work_mem".
(3 rows)

Three things about that warning are the whole of the feature. The severity is a warning rather than a notice, which is what carries it past a default logging threshold and into whatever reads your logs. It names the database, schema and table, which makes it actionable without a lookup, and only the database part of that is trimmed above. And it says the failsafe was entered after zero index scans, so it engaged before doing any index work rather than partway through.

The hint underneath it is worth reading as advice rather than as boilerplate. More maintenance memory genuinely helps here, because it is what decides how many dead row pointers a single pass can hold and therefore how many passes an ordinary vacuum needs. The second sentence of it is the real message, which is that the cluster is generating transaction IDs faster than vacuum is retiring them, and no setting fixes that.

The floor nobody mentions

The setting looks like it takes any value and it does not. Its effective value is raised to at least one hundred and five percent of autovacuum_freeze_max_age, so lowering it below that has no effect whatsoever, which is why this page’s fixture had to lower both.

SELECT current_setting('vacuum_failsafe_age')      AS failsafe_age_set_to,
       current_setting('autovacuum_freeze_max_age') AS freeze_max_age,
       (current_setting('autovacuum_freeze_max_age')::bigint * 105 / 100) AS effective_floor;
 failsafe_age_set_to | freeze_max_age | effective_floor 
---------------------+----------------+-----------------
 1                   | 100000         |          105000
(1 row)

The configured value here is one, and the floor is a hundred and five thousand, and it is the floor that decided when the failsafe fired. On a default cluster the floor is above two hundred million, so a well-meant change setting the failsafe to fifty million achieves nothing and reports nothing. Test the value you intend to use on a cluster you can burn transaction IDs on, rather than trusting that the setting took.

This also settles a question people ask in the other direction. Because the floor is tied to the autovacuum freeze horizon, lowering that horizon to make wraparound vacuums start earlier also lowers the point at which the emergency gear engages. The two settings move together whether or not you meant them to.

What survives the upgrade, and what gets better

The mechanism is unchanged on later majors and the warning text is the same, so nothing on this page breaks on the way out of 14. What changed is how visible the event is afterwards.

VACUUM (VERBOSE) fs_probe;
SELECT replace(regexp_replace(line, '^\d{4}-\d\d-\d\d \S+ \S+ \[\d+\] ', ''),
               current_database() || '.', '') AS server_log
FROM pg_ls_logdir() f,
     LATERAL regexp_split_to_table(pg_read_file('log/' || f.name), E'\n') AS line
WHERE line LIKE '%as a failsafe after%'
   OR line LIKE '%relfrozenxid or relminmxid%'
   OR line LIKE '%maintenance_work_mem%'
   OR line LIKE '%bypassed by failsafe%';
VACUUM
                                                    server_log                                                    
------------------------------------------------------------------------------------------------------------------
 WARNING:  bypassing nonessential maintenance of table "public.fs_probe" as a failsafe after 0 index scans
 DETAIL:  The table's relfrozenxid or relminmxid is too far in the past.
 HINT:  Consider increasing configuration parameter "maintenance_work_mem" or "autovacuum_work_mem".
         index scan bypassed by failsafe: 109 pages from table (100.00% of total) have 6666 dead item identifiers
(4 rows)

The first three lines are identical. The fourth is new: the run now reports that the index scan was bypassed, with the page count and the number of dead item pointers it left behind. On 14 that number exists nowhere. The warning says the failsafe fired, and the run then finishes without saying what it skipped, so the bloat it deferred is invisible until somebody goes and measures the table.

That matters for the post-incident half of this, which is the half people skip. A failsafe vacuum leaves index entries pointing at removed rows, and they stay until an ordinary vacuum cleans them. On 18 you can read how much was left. On 14 you have to go and look at the table, and a fleet still on 14 should assume the answer is “a lot” rather than assuming the emergency is over because the warning stopped.

The twin setting, for the counter nobody watches

There are two failsafes and the second one exists for a counter most fleets have never graphed. Multixacts are how PostgreSQL records that several transactions hold a lock on one row at the same time, and they have their own identifier space, their own wraparound, and their own age on every table. A cluster can be comfortable on transaction IDs and close to the limit on multixacts, and the usual dashboard shows only the first.

The multixact setting works the same way, with the same floor mechanism against the multixact freeze horizon, and it fires the same warning. What differs is how a cluster gets there. Transaction ID age climbs with write volume, steadily and predictably, so it is the kind of thing a trend line catches weeks out. Multixact age climbs with a particular pattern instead: many concurrent foreign key checks or explicit row locks on the same rows, which an application can start doing after a deploy without changing its write volume at all. The curve is flat and then it is not.

That makes the multixact case the one more likely to arrive as a surprise, and it is why the wraparound treatment worth reading is the one that covers both counters rather than only the familiar one.

The practical consequence for this page is small and worth stating. Whatever you build to watch table age against the effective failsafe threshold should read both ages and both horizons, because the two settings are independent, the two floors are computed from different parameters, and a table can cross one while nowhere near the other.

What to alert on

  • Table age against the failsafe floor, not against the setting. The number that matters is the effective value computed above, and it is the one that will actually fire.
  • The warning itself in the log, as a page rather than a graph. A failsafe vacuum is an incident that has already started, and it is one of the few log lines that deserves to wake somebody.
  • Bloat on any table that triggered one, checked afterwards rather than during. This is the part that gets forgotten because the alert clears when the vacuum finishes.

Do not alert on the setting’s value. It is a floor-adjusted number and reading it from the configuration tells you less than reading it from the server.

What it costs

The setting itself costs nothing: it is a comparison made when a vacuum starts and re-checked periodically as it runs. It needs no restart and can be set per session, which is how you would use it deliberately, by lowering it for one manual vacuum on one table rather than changing it cluster-wide.

What costs something is the mode it turns on. An emergency vacuum takes as much I/O as the device will give it, which is the point, and on a cluster already struggling that is felt by everything else. Treat it as the thing that stops an outage rather than as the thing that prevents one. Prevention is elsewhere: it is the freeze horizon and the vacuum settings that decide whether a table ever gets near this at all.

Put every Postgres you run on autopilot.

We onboard teams in small batches. Tell us about your fleet and we will reach out when a seat opens. One email, no drip campaign.