PostgreSQL in Depth Module 5 – MVCC, VACUUM and freezing
Freezing and transaction ID wraparound
Why 32-bit transaction IDs make PostgreSQL 18 freeze old rows, how to read table and database ages against the limits, and what autovacuum does near them.
What you will learn
- Explain why 32-bit transaction IDs force PostgreSQL to freeze old rows
- Monitor age(relfrozenxid), age(datfrozenxid) and multixact ages against the safety limits
- Explain aggressive vacuums, the failsafe and PostgreSQL 18's eager freezing
- Follow the documented recovery steps when a database stops assigning transaction IDs
Before you start
What you’ll learn · Before you start
What you will learn
- Explain why 32-bit transaction IDs force PostgreSQL to freeze old rows
- Monitor age(relfrozenxid), age(datfrozenxid) and multixact ages against the safety limits
- Explain aggressive vacuums, the failsafe and PostgreSQL 18's eager freezing
- Follow the documented recovery steps when a database stops assigning transaction IDs
Before you start
On this page
PostgreSQL gives every transaction that writes a 32-bit ID, and it can tell an older ID from a newer one only while they are less than about 2.1 billion apart. A server that commits 2,000 writing transactions a second spends that many in about 12 days, and an unfrozen row written before then would look as if it came from the future, invisible to every query. freezing: Marking a row as visible to every transaction, so that its transaction ID no longer takes part in comparisons. VACUUM sets a flag in the row's header and keeps its xmin, and a page whose rows are all frozen is marked all-frozen in the visibility map. prevents that transaction ID wraparound: What would happen to a row whose 32-bit transaction ID was left unfrozen for more than about 2.1 billion transactions: compared modulo 2³², the ID would look newer than the present and the row would vanish from queries. Freezing old rows in time prevents it.: VACUUM marks old rows as visible to every transaction, and autovacuum: The launcher and worker processes that run VACUUM and ANALYZE on tables by themselves whenever a table's counts of dead, inserted or changed rows exceed thresholds worked out from its size and the settings. More in “VACUUM and autovacuum: triggers and tuning” forces it on each table long before the counter gets close.
You will freeze a table, read table and database ages against the limits, and then watch aggressive vacuums, PostgreSQL 18’s eager freezing, the failsafe, the documented recovery and multixacts at work on PostgreSQL 18.6.
A 32-bit counter tells past from future for about 2 billion IDs
Every row version records in its xmin the transaction that wrote it, and a query decides whether to see the row by comparing that ID with its snapshot. The IDs come from one counter for the whole cluster, and a transaction takes one only when it first writes. With 32 bits the counter has about 4.3 billion values, so PostgreSQL compares IDs modulo 2³²: for any ID, the 2.1 billion behind it are older and the 2.1 billion ahead of it newer. Step through one row’s life on that circle:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A row is written. Transaction 1,000 inserts a row, and the row's header records 1,000 as its xmin. Every later transaction compares its own ID with 1,000 to decide whether the row was written in its past.
- A million IDs later. At transaction 1,001,000 the difference is 1,000,000, far less than 2³¹, about 2.1 billion. The row counts as older, so every snapshot that started after its commit sees it.
- Past the half-way point. The difference grows by one with every transaction that writes. Once it passes 2³¹, the subtraction modulo 2³² says the opposite, because the counter has gone half-way round the circle and 1,000 now lies ahead of it.
- The row disappears. A committed row that counts as written in the future is invisible to every query. Its bytes are still on disk, but no SELECT returns it. That is transaction ID wraparound, and it is why PostgreSQL never lets the counter get this far.
- Freezing first. Run the same story again, but VACUUM reaches the row while it is still in the past and freezes it. It sets a flag in the row's header and keeps the xmin 1,000 for anyone investigating later.
- The counter goes round. A frozen row is treated as older than every normal transaction ID, so the counter can pass 2³¹ and keep going round while the row stays visible. Freezing every row in time is the job the rest of this lesson measures.
Without freezing, a row more than half the circle old changes sides: its bytes stay on the page, but every query skips it. That is transaction ID wraparound. Since PostgreSQL 9.4, freezing sets a flag in the row’s header and keeps its xmin, and a frozen row counts as older than every normal ID.
Key idea
IDs are compared modulo 2³², so every table in every database must be vacuumed, and its old rows frozen, at least once every 2 billion transactions.
Checkpoint
Check: Why 32-bit IDs need freezing
VACUUM (FREEZE) sets a flag and resets the table’s age
The run creates 10,000 accounts with one INSERT, then lets 5,000 transactions pass that take an ID and write nothing
(spend_xids.sql). Before and after VACUUM (FREEZE) it reads two row headers with pageinspect and the
visibility map: A fork of a table (its file ends in _vm) with two bits per page: whether every row on the page is visible to all transactions, and whether every row is frozen. VACUUM and index-only scans use it. More in “PostgreSQL architecture: processes, memory, files” with pg_visibility:
freeze_session.py
"""Freezing and transaction ID wraparound on PostgreSQL 18.6: freeze.sql, what VACUUM (FREEZE) changes in a row's
header, in the visibility map and in the ages of the table and the database."""
from pg_session import Server
# Autovacuum off: nothing but the session takes transaction IDs, so every ID and age is the same on every run.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off", "timezone": "'UTC'"}
# VACUUM VERBOSE ends with the read and write rates, the buffer and WAL counts and the CPU and elapsed times of the
# run, which differ every time: the record shows one fixed line in their place. Every other line is as printed.
RUN_FIGURES = (r"^avg read rate: .*\nbuffer usage: .*\nWAL usage: .*\nsystem usage: .*$",
"(rates, buffer and WAL counts and times of this run: they differ on every run)")
with Server(settings=SETTINGS) as pg:
pg.up()
pg.psql("spend_xids.sql", flags=("-q",))
pg.psql("freeze.sql", mask=[RUN_FIGURES]) Output
$ psql -X -q -f spend_xids.sql
$ psql -X -a -f freeze.sql
-- 10,000 accounts written by one transaction and read once, then 5,000 transactions that write nothing.
CREATE EXTENSION pageinspect;
CREATE EXTENSION
CREATE EXTENSION pg_visibility;
CREATE EXTENSION
CREATE TABLE accounts (id int PRIMARY KEY, balance int NOT NULL);
CREATE TABLE
INSERT INTO accounts SELECT n, 1000 FROM generate_series(1, 10000) AS n;
INSERT 0 10000
SELECT count(*) FROM accounts;
count
-------
10000
(1 row)
CALL spend_xids(5000);
CALL
-- How old is the table, and what does a row's header say?
SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'accounts';
relfrozenxid | age
--------------+------
756 | 5002
(1 row)
SELECT lp, t_xmin, f.combined_flags FROM heap_page_items(get_raw_page('accounts', 0)),
LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2) AS f WHERE lp <= 2;
lp | t_xmin | combined_flags
----+--------+----------------
1 | 757 | {}
2 | 757 | {}
(2 rows)
SELECT * FROM pg_visibility_map_summary('accounts');
all_visible | all_frozen
-------------+------------
0 | 0
(1 row)
VACUUM (FREEZE, VERBOSE) accounts;
psql:freeze.sql:13: INFO: aggressively vacuuming "postgres.public.accounts"
psql:freeze.sql:13: INFO: finished vacuuming "postgres.public.accounts": index scans: 0
pages: 0 removed, 45 remain, 45 scanned (100.00% of total), 0 eagerly scanned
tuples: 0 removed, 10000 remain, 0 are dead but not yet removable
removable cutoff: 5758, which was 0 XIDs old when operation ended
new relfrozenxid: 5758, which is 5002 XIDs ahead of previous value
frozen: 45 pages from table (100.00% of total) had 10000 tuples frozen
visibility map: 45 pages set all-visible, 45 pages set all-frozen (0 were all-visible)
index scan not needed: 0 pages from table (0.00% of total) had 0 dead item identifiers removed
(rates, buffer and WAL counts and times of this run: they differ on every run)
VACUUM
SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'accounts';
relfrozenxid | age
--------------+-----
5758 | 0
(1 row)
SELECT lp, t_xmin, f.combined_flags FROM heap_page_items(get_raw_page('accounts', 0)),
LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2) AS f WHERE lp <= 2;
lp | t_xmin | combined_flags
----+--------+--------------------
1 | 757 | {HEAP_XMIN_FROZEN}
2 | 757 | {HEAP_XMIN_FROZEN}
(2 rows)
SELECT * FROM pg_visibility_map_summary('accounts');
all_visible | all_frozen
-------------+------------
45 | 45
(1 row)
-- One change clears the bits of the pages it touches.
UPDATE accounts SET balance = 900 WHERE id = 1;
UPDATE 1
SELECT * FROM pg_visibility_map_summary('accounts');
all_visible | all_frozen
-------------+------------
43 | 43
(1 row)
-- A database is as old as its oldest table: freeze them all.
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY datname;
datname | age
-----------+------
postgres | 5014
template0 | 5014
template1 | 5014
(3 rows)
VACUUM (FREEZE);
VACUUM
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY datname;
datname | age
-----------+------
postgres | 0
template0 | 5014
template1 | 5014
(3 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 freeze_session.py
freeze.sql
-- 10,000 accounts written by one transaction and read once, then 5,000 transactions that write nothing.
CREATE EXTENSION pageinspect;
CREATE EXTENSION pg_visibility;
CREATE TABLE accounts (id int PRIMARY KEY, balance int NOT NULL);
INSERT INTO accounts SELECT n, 1000 FROM generate_series(1, 10000) AS n;
SELECT count(*) FROM accounts;
CALL spend_xids(5000);
-- How old is the table, and what does a row's header say?
SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'accounts';
SELECT lp, t_xmin, f.combined_flags FROM heap_page_items(get_raw_page('accounts', 0)),
LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2) AS f WHERE lp <= 2;
SELECT * FROM pg_visibility_map_summary('accounts');
VACUUM (FREEZE, VERBOSE) accounts;
SELECT relfrozenxid, age(relfrozenxid) FROM pg_class WHERE relname = 'accounts';
SELECT lp, t_xmin, f.combined_flags FROM heap_page_items(get_raw_page('accounts', 0)),
LATERAL heap_tuple_infomask_flags(t_infomask, t_infomask2) AS f WHERE lp <= 2;
SELECT * FROM pg_visibility_map_summary('accounts');
-- One change clears the bits of the pages it touches.
UPDATE accounts SET balance = 900 WHERE id = 1;
SELECT * FROM pg_visibility_map_summary('accounts');
-- A database is as old as its oldest table: freeze them all.
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY datname;
VACUUM (FREEZE);
SELECT datname, age(datfrozenxid) FROM pg_database ORDER BY datname; spend_xids.sql
-- spend_xids(n): n transactions in a row, each of which takes a transaction ID and commits without writing a row.
-- It ages every table of the cluster by n transactions in a second or two.
CREATE PROCEDURE spend_xids(n int) LANGUAGE plpgsql AS $$
BEGIN
FOR i IN 1..n LOOP
PERFORM pg_current_xact_id();
COMMIT;
END LOOP;
END $$; pg_session.py
"""Throwaway PostgreSQL 18.6 servers for the examples of this lesson.
Every session creates a cluster of its own with initdb in a new folder inside the examples folder, starts it, runs
psql scripts and server programs on it, prints what they printed, and stops and deletes it again, also when something
fails. The server is the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file.
It listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system reports as
unused, with no Unix socket, and it asks for a password (scram-sha-256) made up for the run and never printed.
learn:verify's run sandbox cannot read the home folder, where mise keeps PostgreSQL, so these sessions are recorded
with LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.
The output is exactly what the programs printed, with four substitutions so that a record never shows the computer
it ran on and is the same on every run: the run's folder is shown as ".", the folder of PostgreSQL's programs is left
out of their paths, the operating-system user's name is shown as "learner", and the dots pg_ctl prints while it waits
(one about every tenth of a second) as three. A session may name more patterns (mask=) for numbers that change on
every run, such as the rates and times ANALYZE VERBOSE prints or the random key of pg_dump's \\restrict line; the
session's own file says which, and its record shows a fixed word in their place. The sessions never print the port.
A session stops with status 1 when a command fails that should have worked, or reports a different number of errors
than the session expects, so a run that went wrong is never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway cluster: initdb, settings, start; stopped and deleted when the with block ends."""
def __init__(self, settings=None, initdb=(), name="data"):
self.settings = dict(settings or {})
self.initdb_args = list(initdb)
self.name = name
self.running = []
def __enter__(self):
if not (BIN / "postgres").exists():
sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="pg", dir=".")).resolve()
self.port = free_port()
self.password = secrets.token_urlsafe(24)
(self.base / "pwfile").write_text(self.password + "\n")
self.env = {**os.environ, "PGHOST": "127.0.0.1", "PGPORT": str(self.port), "PGUSER": "postgres",
"PGPASSWORD": self.password, "PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "10"}
for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC"):
self.env.pop(key, None)
for script in Path(".").glob("*.sql"):
shutil.copy(script, self.base / script.name)
for data in Path(".").glob("*.csv"):
shutil.copy(data, self.base / data.name)
return self
def __exit__(self, *exc):
for folder, _port in reversed(self.running):
self._call(["pg_ctl", "-D", folder, "-m", "fast", "-w", "-t", "60", "stop"])
self.running = []
shutil.rmtree(self.base, ignore_errors=True)
# ---- the cluster ---------------------------------------------------------------------------------------------
def initdb(self, show=False):
args = ["initdb", "-D", self.name, "-U", "postgres", "--auth=scram-sha-256", "--pwfile=pwfile",
*self.initdb_args]
if not show:
args.append("--no-instructions")
self._expect(args, show=show)
with open(self.base / self.name / "postgresql.conf", "a") as conf:
conf.write("\n# Added by the session: 127.0.0.1 only, no Unix socket.\n")
conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.port}\nunix_socket_directories = ''\n")
for key, value in self.settings.items():
conf.write(f"{key} = {value}\n")
def start(self, show=False, folder=None, port=None):
folder = folder or self.name
self._expect(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"], show=show)
self.running.append((folder, port or self.port))
def up(self):
"""initdb and start, quietly."""
self.initdb()
self.start()
return self
def standby(self, folder="standby"):
"""A streaming standby of this server, made with pg_basebackup -R, started on a port of its own."""
self._expect(["pg_basebackup", "-D", folder, "-R", "-X", "stream", "-c", "fast"])
port = free_port()
with open(self.base / folder / "postgresql.conf", "a") as conf:
conf.write(f"port = {port}\n")
self.start(folder=folder, port=port)
for _ in range(300):
if self.query("SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") == "1":
return port
time.sleep(0.1)
sys.exit("the standby did not start streaming")
# ---- running things ------------------------------------------------------------------------------------------
def _call(self, args, env=None):
args = [str(a) for a in args]
done = subprocess.run([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, cwd=self.base, env=env or self.env)
return done.returncode, self.clean(done.stdout)
def _expect(self, args, show=False, status=0, mask=()):
code, out = self._call(args)
out = _masked(out, mask)
if show:
_show(args, out)
if code != status:
if not show:
print(out)
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The run's folder as ".", the user as learner, pg_ctl's waiting dots as three (see the docstring)."""
text = text.replace(str(BIN) + "/", "").replace(str(self.base) + "/", "").replace(str(self.base), ".")
text = re.sub(r"(waiting for server to (?:start|shut down))\.+", r"\1...", text)
return text.replace(f'"{OS_USER}"', '"learner"')
def wait_for(self, sql, value):
"""Waits (unprinted) until a query returns the value, for example after a reload."""
for _ in range(300):
if self.query(sql) == value:
return
time.sleep(0.1)
sys.exit(f"{sql} never returned {value}")
def show_file(self, path):
"""Prints a text file of the cluster, as `cat` would."""
print(f"$ cat {path}")
print((self.base / path).read_text().rstrip("\n"))
print()
def query(self, sql, port=None):
"""One value from one query, unprinted (the session uses it to wait or to decide)."""
env = {**self.env, "PGPORT": str(port or self.port)}
code, out = self._call(["psql", "-X", "-At", "-c", sql], env=env)
if code != 0:
sys.exit(f"the query failed: {out}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only to give a role the run's password)."""
self.query(sql)
def psql(self, script, errors=0, status=None, user=None, db=None, port=None, flags=("-a",), note=None, mask=()):
"""Runs a psql script with -X (no ~/.psqlrc) and the given flags, and prints the command and its output.
`errors` is how many ERROR lines the script must print; `status` the exit status it must end with."""
env = {**self.env, "PGPORT": str(port or self.port)}
if user:
env["PGUSER"] = user
if db:
env["PGDATABASE"] = db
args = ["psql", "-X", *flags, "-f", script]
code, out = self._call(args, env=env)
out = _masked(out, mask)
shown = ["psql", "-X", *flags, "-f", script]
if user:
shown[1:1] = ["-U", user]
if db:
shown[1:1] = ["-d", db]
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if "ERROR:" in line or ": error:" in line)
expected_status = 0 if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
def sessions(self, count):
"""Opens `count` idle psql connections (each one a client backend) until the with block ends."""
return _Idle(self, count)
def shell(self, *args, status=0, mask=()):
"""Runs one of PostgreSQL's programs and prints the command and its output."""
return self._expect(list(args), show=True, status=status, mask=mask)
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the cluster (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"-- in {folder}" + (f", the names that start with {prefix}" if prefix else "") + ":")
print(textwrap.fill(" ".join(names), width=78, initial_indent=" ", subsequent_indent=" ",
break_on_hyphens=False))
print()
return names
def _masked(text, mask):
"""Replaces what differs on every run (a rate, a time, a random key) with fixed text (see the docstring)."""
for pattern, replacement in mask:
text = re.sub(pattern, replacement, text, flags=re.M)
return text
def _arg(a):
"""A command-line argument as a reader would type it: double quotes around text with single quotes in it."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print()
class _Idle:
"""Idle psql connections, closed again when the with block ends."""
def __init__(self, server, count):
self.server, self.count, self.procs = server, count, []
def __enter__(self):
before = int(self.server.query("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"))
for _ in range(self.count):
self.procs.append(subprocess.Popen([str(BIN / "psql"), "-X", "-q"], stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=self.server.env, cwd=self.server.base))
sql = "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"
for _ in range(300):
# the query's own connection is one more client backend
if int(self.server.query(sql)) == before + self.count:
return self
time.sleep(0.1)
sys.exit("the idle connections did not open")
def __exit__(self, *exc):
for p in self.procs:
p.stdin.close()
p.wait(timeout=30) Before, the table’s relfrozenxid was 756, the oldest ID that could be unfrozen in it, and its age 5,002. The rows had
xmin 757 and no flag, and no page was marked. VACUUM (FREEZE) scanned all 45 pages and froze 10,000 rows: the same
rows then showed HEAP_XMIN_FROZEN with xmin still 757, all 45 pages were all-frozen and the age was 0. One UPDATE
cleared both bits on the two pages it wrote, which the next VACUUM must read again. A database is as old as its oldest
table, system catalog: One of the tables in the pg_catalog schema where PostgreSQL records its objects, such as pg_class for relations, pg_attribute for columns and pg_constraint for constraints. They can be queried like any table. More in “System catalogs, pages and TOAST” included: VACUUM (FREEZE) without a table name took postgres from 5,014 to 0, while template0 and
template1 stayed at 5,014, because VACUUM reaches only the database it runs in.
Checkpoint
Check: What VACUUM (FREEZE) changes
The ages at which PostgreSQL steps in
The next run makes three tables, freezes orders 20,000 transactions later and carts 10,000 after that, then runs the manual’s age query and each database’s distance to the hard limits:
ages_session.py
"""Freezing and transaction ID wraparound on PostgreSQL 18.6: ages.sql, the ages of three tables and of the databases,
measured against autovacuum_freeze_max_age and against the points where warnings start and new IDs are refused."""
from pg_session import Server
# Autovacuum off: nothing but the session takes transaction IDs, so every ID and age is the same on every run.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off", "timezone": "'UTC'"}
with Server(settings=SETTINGS) as pg:
pg.up()
pg.psql("spend_xids.sql", flags=("-q",))
pg.psql("ages.sql") Output
$ psql -X -q -f spend_xids.sql
$ psql -X -a -f ages.sql
-- Three tables: audit_log is never vacuumed, orders is frozen 20,000 transactions later, carts 30,000 later.
CREATE TABLE audit_log (id int PRIMARY KEY, note text NOT NULL);
CREATE TABLE
CREATE TABLE orders (id int PRIMARY KEY, total int NOT NULL);
CREATE TABLE
CREATE TABLE carts (id int PRIMARY KEY, items int NOT NULL);
CREATE TABLE
INSERT INTO audit_log SELECT n, 'login ' || n FROM generate_series(1, 1000) AS n;
INSERT 0 1000
INSERT INTO orders SELECT n, n % 900 FROM generate_series(1, 1000) AS n;
INSERT 0 1000
INSERT INTO carts SELECT n, 1 FROM generate_series(1, 1000) AS n;
INSERT 0 1000
CALL spend_xids(20000);
CALL
VACUUM (FREEZE) orders;
VACUUM
CALL spend_xids(10000);
CALL
VACUUM (FREEZE) carts;
VACUUM
-- The manual's age query, oldest first, with the transactions left before autovacuum must vacuum each table.
SELECT c.oid::regclass AS table_name,
greatest(age(c.relfrozenxid), age(t.relfrozenxid)) AS xid_age,
current_setting('autovacuum_freeze_max_age')::int
- greatest(age(c.relfrozenxid), age(t.relfrozenxid)) AS left_before_forced_vacuum
FROM pg_class c LEFT JOIN pg_class t ON t.oid = c.reltoastrelid
WHERE c.relkind IN ('r', 'm') AND c.relnamespace = 'public'::regnamespace
ORDER BY xid_age DESC;
table_name | xid_age | left_before_forced_vacuum
------------+---------+---------------------------
audit_log | 30006 | 199969994
orders | 10000 | 199990000
carts | 0 | 200000000
(3 rows)
-- A database is as old as its oldest table, catalogs included: the distance to the warnings and to the stop.
SELECT datname, age(datfrozenxid) AS xid_age,
2147483647 - 40000000 - age(datfrozenxid) AS left_before_warnings,
2147483647 - 3000000 - age(datfrozenxid) AS left_before_stop
FROM pg_database ORDER BY datname;
datname | xid_age | left_before_warnings | left_before_stop
-----------+---------+----------------------+------------------
postgres | 30015 | 2107453632 | 2144453632
template0 | 30015 | 2107453632 | 2144453632
template1 | 30015 | 2107453632 | 2144453632
(3 rows)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ages_session.py
ages.sql
-- Three tables: audit_log is never vacuumed, orders is frozen 20,000 transactions later, carts 30,000 later.
CREATE TABLE audit_log (id int PRIMARY KEY, note text NOT NULL);
CREATE TABLE orders (id int PRIMARY KEY, total int NOT NULL);
CREATE TABLE carts (id int PRIMARY KEY, items int NOT NULL);
INSERT INTO audit_log SELECT n, 'login ' || n FROM generate_series(1, 1000) AS n;
INSERT INTO orders SELECT n, n % 900 FROM generate_series(1, 1000) AS n;
INSERT INTO carts SELECT n, 1 FROM generate_series(1, 1000) AS n;
CALL spend_xids(20000);
VACUUM (FREEZE) orders;
CALL spend_xids(10000);
VACUUM (FREEZE) carts;
-- The manual's age query, oldest first, with the transactions left before autovacuum must vacuum each table.
SELECT c.oid::regclass AS table_name,
greatest(age(c.relfrozenxid), age(t.relfrozenxid)) AS xid_age,
current_setting('autovacuum_freeze_max_age')::int
- greatest(age(c.relfrozenxid), age(t.relfrozenxid)) AS left_before_forced_vacuum
FROM pg_class c LEFT JOIN pg_class t ON t.oid = c.reltoastrelid
WHERE c.relkind IN ('r', 'm') AND c.relnamespace = 'public'::regnamespace
ORDER BY xid_age DESC;
-- A database is as old as its oldest table, catalogs included: the distance to the warnings and to the stop.
SELECT datname, age(datfrozenxid) AS xid_age,
2147483647 - 40000000 - age(datfrozenxid) AS left_before_warnings,
2147483647 - 3000000 - age(datfrozenxid) AS left_before_stop
FROM pg_database ORDER BY datname; audit_log, never vacuumed, was 30,006 transactions old, orders 10,000 and carts 0. The query counts each table’s
TOAST: PostgreSQL's storage for wide values: once a row passes about 2 kB, its large values are compressed and, if the row is still too wide, moved out of line into the table's TOAST table in chunks. More in “System catalogs, pages and TOAST” table too and subtracts the age from autovacuum_freeze_max_age, 200 million by default: audit_log had
199,969,994 transactions left before autovacuum must vacuum it. The databases were 30,015 old: catalogs count too.
The server puts wraparound 2,147,483,647 IDs after the oldest unfrozen one, warns 40 million before it and refuses new
IDs 3 million before it, which left these databases 2,107,453,632 and 2,144,453,632 transactions. Walk through the
ages:
- age 0
Just frozen
The age of a table is the number of transactions since its relfrozenxid, the oldest transaction ID that may remain unfrozen in it. VACUUM (FREEZE) took the lesson's accounts table from 5,002 to 0.
- 50 million
Old enough to freeze
vacuum_freeze_min_age: VACUUM freezes the pages it reads that hold a row older than this. Pages of younger rows are left alone, because rows that are about to change again would be frozen for nothing.
- 150 million
The next VACUUM is aggressive
vacuum_freeze_table_age: a VACUUM of a table this old reads every page that is not yet all-frozen, not only the pages with dead rows, so that it can move relfrozenxid forward.
- 200 million
Autovacuum is forced to run set at server start
autovacuum_freeze_max_age: the server vacuums the table to prevent wraparound even when autovacuum is switched off for it or for the whole server.
- 1.6 billion
The failsafe takes over
vacuum_failsafe_age: a VACUUM that finds its table this old drops its cost delay and skips index work, to finish freezing as soon as it can.
- 40 million left
Warnings on every new ID
When the oldest database is 40 million transactions from wraparound, each new transaction ID comes with a warning that the database "must be vacuumed within" so many transactions. PostgreSQL 19, still in preparation, starts at 100 million.
- 3 million left
New IDs are refused writes stop
The server refuses every command that needs a new transaction ID. Read-only transactions still start and VACUUM still runs, which is how a database gets back from here.
- 2³¹
Wraparound, never reached
Half-way round the 32-bit circle, the oldest unfrozen rows would look newer than the present. The 3-million-transaction stop exists so that this point is never reached.
The timeline has 8 events, from age 0 to 2³¹.
An aggressive vacuum: A VACUUM that reads every page not yet marked all-frozen, not only the pages with dead rows, so that it can move the table's relfrozenxid forward. It happens once a table is older than vacuum_freeze_table_age, 150 million transactions by default, and with VACUUM (FREEZE). reads every page not yet all-frozen, so it can lower a table’s age. PostgreSQL 18’s eager scanning lets ordinary vacuums freeze some all-visible pages early. The failsafe (VACUUM): VACUUM's last resort against transaction ID wraparound: once a table is older than vacuum_failsafe_age, 1.6 billion transactions by default, VACUUM drops its cost delay and skips index work to finish freezing as soon as it can. drops VACUUM’s cost-based vacuum delay: A throttle on VACUUM and ANALYZE: every page found in memory, read from disk or dirtied adds to a cost count, and when the count reaches a limit the process sleeps for a set delay before it goes on. More in “VACUUM and autovacuum: triggers and tuning” and skips index work to finish freezing sooner. multixact: A multiple-transaction ID: the number PostgreSQL stores in a row's xmax when several transactions lock that row at once. It comes from a second 32-bit counter, so multixact IDs age and must be frozen like transaction IDs., the IDs of row locks that several transactions share, need freezing too. The manual’s recovery from the stop clears what holds old IDs, then runs a plain VACUUM.
Earlier warnings in PostgreSQL 19
PostgreSQL 19, still in preparation, starts the warnings 100 million transactions before wraparound instead of 40 million, for transaction IDs and multixacts alike.
Checkpoint
Check: Table and database ages against the limits
Practise: ages and limits
Every question below uses the two runs and the defaults above; work the numbers out before you check them.
Practice that adapts to you
Practise this lesson
8 questions about 10 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Aggressive vacuums and PostgreSQL 18's eager freezing, measured Pro: opens with a Pro, Premium or Ultimate pass
An ordinary VACUUM skips the pages it already marked all-visible, so a table can be vacuumed often and still grow old. These runs measure that gap, the aggressive vacuum that closes it, and the eager scanning PostgreSQL 18 adds to spread its cost.
In this part:
- Why a normal VACUUM leaves the table’s age behind, measured
- PostgreSQL 18’s eager scanning, with its caps in numbers
2 runnable examples (aggressive.sql, aggressive_session.py, eager.sql, eager_session.py) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
The forced vacuum, the failsafe and the way back from the stop Pro: opens with a Pro, Premium or Ultimate pass
The safety net has three layers: a vacuum the server forces on old tables, a failsafe inside VACUUM, and the
manual’s recovery when both come too late. The runs reproduce the first two with autovacuum_freeze_max_age at its
smallest value, 100,000, and the third with a forgotten prepared transaction.
In this part:
- A forced autovacuum that ran and still left the table old
- The failsafe reproduced: freezing first, index work later
- Recovering a database that refuses new transaction IDs, step by step
1 diagram · 2 runnable examples (diagnose.sql, failsafe.sql, failsafe_session.py, fixed.sql, forced.sql, forced_session.py) · about 10 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Multixacts: a second counter that needs freezing Pro: opens with a Pro, Premium or Ultimate pass
When more than one transaction locks the same row, the row’s xmax cannot name them all, so PostgreSQL stores a multixact ID there instead. Multixact IDs come from their own 32-bit counter, with their own ages, settings and limits; these runs create one, age the counter and vacuum it.
In this part:
- Two foreign-key checks on one customer make a multixact
- Multixact ages, their limits and what changes in recovery
1 runnable example (multixact.sql, multixact_ages.sql, multixact_session.py, multixact_setup.sql) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Interview questions
Three incidents to rehearse: an age that keeps climbing, a database that refuses writes, and an append-only table whose forced vacuums read most of it. Each row leads to the bank’s opening move and follow-ups.
-
Mid to senior
An alert fires: age(datfrozenxid) of the orders database is 1.2 billion and grows by about 30 million transactions a day, although autovacuum keeps running and autovacuum_freeze_max_age is the default 200 million. What do you check first, how many days do you have, and what do you change?
-
Senior to staff
In the middle of the night every write to a PostgreSQL 18 database fails with "database is not accepting commands that assign new transaction IDs to avoid wraparound data loss". Reads still work. Walk through your recovery in order, what you check at each step, and what you avoid.
-
Senior to staff
A 3 TB append-only events table is never updated or deleted from, yet every few days an autovacuum "to prevent wraparound" reads most of it and saturates the disks for hours. Explain why a table without dead rows gets these vacuums, and how you would make them cheaper on PostgreSQL 18.
Flashcards · 4 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why must every table be frozen within about 2 billion transactions?
Transaction IDs are 32-bit and compared modulo 2^32: the ~2.1 billion behind an ID are older, the ~2.1 billion ahead newer. An unfrozen row more than half the circle old would look like it came from the future and be invisible — transaction ID wraparound.
-
What does VACUUM (FREEZE) do, and how far does it reach?
It sets a frozen flag in old rows (keeping their xmin) so they count as older than every normal ID, and resets the table's age to 0. A database is as old as its oldest table (catalogs included), and VACUUM reaches only the database it runs in.
-
At which ages does PostgreSQL step in to force freezing?
autovacuum_freeze_max_age is 200 million by default. Wraparound sits 2,147,483,647 IDs after the oldest unfrozen one; the server warns 40 million before it (100 million in PostgreSQL 19) and refuses new IDs 3 million before it.
-
What are aggressive vacuums, eager scanning and the failsafe?
An aggressive vacuum reads every page not yet all-frozen, so it can lower a table's age. PostgreSQL 18's eager scanning freezes some all-visible pages early. The failsafe drops VACUUM's cost-based delay and skips index work to finish freezing sooner; multixacts need freezing too.
Key takeaways
- Transaction IDs are 32-bit and compared modulo 2³², so an unfrozen row stays in the past for about 2.1 billion transactions; then comes transaction ID wraparound.
- Freezing sets a flag and keeps xmin: VACUUM (FREEZE) took 10,000 accounts from age 5,002 to 0, and one UPDATE cleared two of their 45 all-frozen pages.
- A table’s age is age(relfrozenxid), and a database’s, age(datfrozenxid), is that of its oldest table, catalogs and TOAST tables included.
- Past autovacuum_freeze_max_age, 200 million by default, autovacuum vacuums a table even where it is off; warnings start 40 million transactions before wraparound, and writes stop at 3 million.
Terms in this lesson
- aggressive vacuum
- A VACUUM that reads every page not yet marked all-frozen, not only the pages with dead rows, so that it can move the table's relfrozenxid forward. It happens once a table is older than vacuum_freeze_table_age, 150 million transactions by default, and with VACUUM (FREEZE).
- failsafe (VACUUM)
- VACUUM's last resort against transaction ID wraparound: once a table is older than vacuum_failsafe_age, 1.6 billion transactions by default, VACUUM drops its cost delay and skips index work to finish freezing as soon as it can.
- freezing
- Marking a row as visible to every transaction, so that its transaction ID no longer takes part in comparisons. VACUUM sets a flag in the row's header and keeps its xmin, and a page whose rows are all frozen is marked all-frozen in the visibility map.
- multixact
- A multiple-transaction ID: the number PostgreSQL stores in a row's xmax when several transactions lock that row at once. It comes from a second 32-bit counter, so multixact IDs age and must be frozen like transaction IDs.
- transaction ID wraparound
- What would happen to a row whose 32-bit transaction ID was left unfrozen for more than about 2.1 billion transactions: compared modulo 2³², the ID would look newer than the present and the row would vanish from queries. Freezing old rows in time prevents it.
References
- PostgreSQL 18 documentation: 24.1 Routine Vacuuming (24.1.5 Preventing Transaction ID Wraparound Failures) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.10 Vacuuming (19.10.4 Freezing) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 67.1 Transactions and Identifiers (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: VACUUM (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 9.28 System Information Functions (age, mxid_age, pg_get_multixact_members) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 52.11 pg_class (relfrozenxid, relminmxid) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 52.15 pg_database (datfrozenxid, datminmxid) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: F.36 pg_visibility (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: F.25 pageinspect (heap_tuple_infomask_flags) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 19.4 Resource Consumption (vacuum_buffer_usage_limit) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: CREATE TABLE (storage parameters) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 53.17 pg_prepared_xacts (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 53.20 pg_replication_slots (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: 13.3 Explicit Locking (row-level lock modes) (The PostgreSQL Global Development Group)
- PostgreSQL 18 documentation: F.31 pgrowlocks (The PostgreSQL Global Development Group)
- PostgreSQL 18 release notes (The PostgreSQL Global Development Group)
- PostgreSQL 18 source: src/backend/access/heap/vacuumlazy.c (eager scanning) (The PostgreSQL Global Development Group)
- PostgreSQL 18 source: src/backend/access/transam/varsup.c (wraparound limits) (The PostgreSQL Global Development Group)
- PostgreSQL 18 source: src/backend/access/heap/pruneheap.c (pages with dead items) (The PostgreSQL Global Development Group)
- PostgreSQL 19 release notes (in preparation) (The PostgreSQL Global Development Group)
- PostgreSQL 19 documentation (in preparation): 24.1 Routine Vacuuming (The PostgreSQL Global Development Group)
Report a problem with this lesson
Kept only in this browser. Your Learn progress