Skip to content
dbexplore

Glossary: Vacuum and freezing

Freeze, and what relfrozenxid records

Also called: freezing, frozen tuple.

Definition, revised in place. Last updated .

Freezing is how PostgreSQL retires a transaction id from a row version. Vacuum marks a version as unconditionally visible, stops relying on the id that created it, and then records on the table, in pg_class.relfrozenxid, the oldest id that could still appear anywhere in its heap. Transaction ids are a counter that wraps, so a table whose relfrozenxid never advances eventually becomes unreadable. Freezing is not a space optimisation. It is the thing that keeps the counter usable.

What the number on the table promises

relfrozenxid is a claim about the whole heap: no row version in this table carries an id older than this one. Vacuum may only move it forward after it has considered every page that could still hold an unfrozen row, which makes the number a property of a complete pass rather than of the pages a pass happened to visit. age(relfrozenxid) turns it into a distance from the current counter, and that distance is what autovacuum compares against autovacuum_freeze_max_age when it decides to start work nobody asked for.

The consequence operators meet first is the anti-wraparound pass: a table nobody has written to in months is suddenly vacuumed, at a priority that ignores the cost delay and does not yield to a conflicting lock request the way an ordinary pass does. That pass is not the problem. Cancelling it repeatedly, which is the common reflex, is.

The second consequence is quieter and costs more. Freezing rewrites heap pages, and a rewritten page is a dirty page, so a freeze-heavy vacuum becomes write-ahead log volume and checkpoint pressure some minutes later. A large cold table freezes cheaply when it is frozen a little at a time and expensively when it is left until the threshold forces all of it at once.

Watching the age fall to zero

The distance is visible per table, and an explicit freeze closes it.

CREATE TABLE audit_row (id int PRIMARY KEY, note text);
INSERT INTO audit_row SELECT g, 'n' FROM generate_series(1, 1000) g;
SELECT age(relfrozenxid) AS unfrozen_age FROM pg_class WHERE relname = 'audit_row';
VACUUM (FREEZE) audit_row;
SELECT age(relfrozenxid) AS unfrozen_age FROM pg_class WHERE relname = 'audit_row';
CREATE TABLE
INSERT 0 1000
 unfrozen_age 
--------------
            2
(1 row)

VACUUM
 unfrozen_age 
--------------
            0
(1 row)

The age of two before the pass is the two transactions the fixture itself spent, which is the smallest self-reference in this collection and a useful reminder that the counter moves while you measure it. Zero afterwards is what an explicit FREEZE produces and what an ordinary vacuum will not: a normal pass leaves alone every row younger than vacuum_freeze_min_age, so the age after routine autovacuum settles somewhere below the threshold rather than at the bottom.

Keeping the number moving

The failure this term describes has one shape and several causes, and the treatment that separates them is the guide on transaction id wraparound, which covers what the age actually has to reach before the server refuses writes and what the emergency looks like from the log. If the age is climbing on one table while the rest of the cluster is fine, the cause is usually something pinning the horizon rather than a vacuum that is too slow, and autovacuum and table bloat is where the three distinct causes are separated.

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.