Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 15 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 5 of 15 minutes free
Free: A 32-bit counter tells past from future for about 2 billion IDs · VACUUM (FREEZE) sets a flag and resets the table's age · The ages at which PostgreSQL steps in · Practise: ages and limits. With a pass: 3 in-depth parts and 3 interview answers, about 10 minutes.

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

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:

Interactive · step by step One row's xmin against a 32-bit counter Step through the life of one row while the transaction counter moves on, first without freezing and then with it, and watch the comparison that decides whether the row is in the past.
One row's xmin against a 32-bit counter, step 6 of 6. Showing Next transaction (about 2.15 billion), Row: xmin 1,000 (frozen: in the past), Compare mod 2³² (frozen: no test), VACUUM (flag set, xmin kept). In focus: Next transaction, Row: xmin 1,000.compares withfreezesNext transactionabout 2.15 billionRow: xmin 1,000frozen: in the pastCompare mod 2³²frozen: no testVACUUMflag set, xmin kept

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 A server commits 5,000 writing transactions a second. About how many days does it take to spend 2,147,483,648 transaction IDs, half the 32-bit circle?

    Type a number.

    A hint

    A day has 86,400 seconds.

    Show the answer: Question 1 of 2

    Answer: 4.97 days (anything from 4.92 to 5.02 counts)

    5,000 × 86,400 = 432,000,000 IDs a day, and 2,147,483,648 ÷ 432,000,000 = 4.97 days. A row left unfrozen for longer than that on such a server would change sides.

  2. Question 2 of 2 Why does a frozen row stay visible after the counter has gone more than half-way round?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A frozen row counts as older than every normal transaction ID, whatever the counter says

    Since PostgreSQL 9.4 freezing sets a flag and keeps xmin, and frozen rows are treated as older than every normal ID, which is what the last step of the scene shows.

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:

Freezing 10,000 accounts and reading their row headers

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

  1. Question 1 of 2 What did VACUUM (FREEZE) change in the headers of the account rows?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It set the HEAP_XMIN_FROZEN flag and left xmin at 757

    The table's age fell from 5,002 to 0 because every row on every page was frozen, which also set all 45 pages all-frozen in the visibility map.

  2. Question 2 of 2 After VACUUM (FREEZE) without a table name in the postgres database, postgres was 0 transactions old while template0 and template1 were still 5,014. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A VACUUM reaches only the tables of the database it runs in

    A database's age is that of its oldest table, catalogs included, so every database of the cluster needs its own vacuums.

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:

Three tables, three ages, and the distance to the 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:

Timeline · 8 events in order The ages at which PostgreSQL steps in Walk through the ages a table can reach, from the moment it is frozen to the point where the server stops assigning transaction IDs, with the default setting that names each step.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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

  1. Question 1 of 2 A server takes 1,000 transaction IDs a second. A table has just been frozen with VACUUM (FREEZE), so its age is 0. After how many days does autovacuum_freeze_max_age, at its default, make autovacuum vacuum it?

    Type a number.

    A hint

    The default is 200 million transactions.

    Show the answer: Question 1 of 2

    Answer: 2.31 days (anything from 2.29 to 2.33 counts)

    1,000 × 86,400 = 86,400,000 IDs a day; 200,000,000 ÷ 86,400,000 = 2.31 days. On a busy server a table that is never vacuumed for other reasons is vacuumed for its age every few days.

  2. Question 2 of 2 A table was created WITH (autovacuum_enabled = off). What happens when its age passes autovacuum_freeze_max_age?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Autovacuum vacuums it anyway, to prevent wraparound

    200 million by default is the age at which the server vacuums a table whatever its other settings, which is why the age query subtracts each table's age from it.

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.

  1. Question 1 of 8 Which of these transactions takes a transaction ID?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: An UPDATE that changes one row

    Only writing transactions use up the counter, so the speed at which IDs run out is the rate of writing transactions, not of queries.

  2. Question 2 of 8 A server averages 500 writing transactions a second. How many days does it take to spend 2,147,483,648 transaction IDs?

    Type a number.

    Show the answer: Question 2 of 8

    Answer: 49.7 days (anything from 49.5 to 49.9 counts)

    500 × 86,400 = 43,200,000 IDs a day, and 2,147,483,648 ÷ 43,200,000 = 49.7 days: less than two months for a server of modest size.

  3. Question 3 of 8 Since PostgreSQL 9.4, what does freezing do to a row's xmin?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: It leaves xmin as it was and sets a flag in the row's header

    Keeping xmin preserves who wrote the row, while the flag makes the comparison irrelevant.

  4. Question 4 of 8 All 45 pages of the accounts table are all-frozen. One UPDATE changes one row. What does the visibility map show afterwards?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: 43 pages all-visible and all-frozen; the two pages the UPDATE wrote lost both bits

    The next VACUUM must read those pages again before the table can be all-frozen once more.

  5. Question 5 of 8 A nightly job runs VACUUM (FREEZE) in the database app, and app's age falls to 0. A monitor of the cluster's oldest database still reports 180 million. What explains it?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: Another database of the cluster has not been vacuumed; each VACUUM reaches only its own database

    The cluster is as close to wraparound as its oldest database, so every database needs vacuums, which is autovacuum's job.

  6. Question 6 of 8 The run's databases were 30,015 transactions old. How many transactions did they have left before the warnings start?

    Type a number.

    Show the answer: Question 6 of 8

    Answer: 2107453632 transactions

    2,147,483,647 − 40,000,000 − 30,015 = 2,107,453,632, the left_before_warnings column of the run.

  7. Question 7 of 8 On a server that takes 300 transaction IDs a second, a table is frozen with VACUUM (FREEZE). How many days later does the default autovacuum_freeze_max_age force a vacuum of it?

    Type a number.

    Show the answer: Question 7 of 8

    Answer: 7.72 days (anything from 7.67 to 7.77 counts)

    300 × 86,400 = 25,920,000 IDs a day, and 200,000,000 ÷ 25,920,000 = 7.72 days.

  8. Question 8 of 8 Every user table of a database is less than 1 million transactions old, yet age(datfrozenxid) of that database is 180 million. What explains it?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: A system catalog or a TOAST table has not been frozen for 180 million transactions

    The run's databases were 30,015 old, older than any of its three tables, because the catalogs were older still.

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.

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.

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.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 4 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.