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

VACUUM and autovacuum: triggers and tuning

How PostgreSQL 18 decides when autovacuum runs VACUUM and ANALYZE on a table, how to read its log entry and progress view, and how to tune it table by table.

  • Advanced
  • 20 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 20 minutes free
Free: VACUUM cleans up after changes, autovacuum decides when · Three thresholds decide when a table is due · Practise: the next trigger, worked out. With a pass: 3 in-depth parts and 3 interview answers, about 14 minutes.

What you will learn

  • Compute when autovacuum vacuums and analyses a table, including PostgreSQL 18's cap and its insert rule
  • Read an autovacuum log entry and the VACUUM progress view
  • Tune the cost budget, the number of workers and per-table storage parameters
  • Recognise when autovacuum cannot keep up and decide what to change first

Before you start

What you’ll learn · Before you start

What you will learn

  • Compute when autovacuum vacuums and analyses a table, including PostgreSQL 18's cap and its insert rule
  • Read an autovacuum log entry and the VACUUM progress view
  • Tune the cost budget, the number of workers and per-table storage parameters
  • Recognise when autovacuum cannot keep up and decide what to change first

Before you start

On this page

A table of 10,000 rows is vacuumed automatically once more than 2,050 of its rows are dead, and a table of a billion rows once more than 100 million are, a cap that PostgreSQL 18 added: the formula alone would wait for 200 million. VACUUM is the command that makes the space of dead tuple: A row version whose deleting or updating transaction has committed, so no new query returns it. It keeps its space on the page until VACUUM removes it, which VACUUM may do only once that transaction is older than the xmin horizon. More in “MVCC in production: the xmin horizon” reusable and keeps a table’s 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”, frozen rows and, with ANALYZE, statistics up to date; 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. runs VACUUM and ANALYZE by itself whenever a table’s counts of changed rows exceed thresholds like these.

The first sections compute those thresholds for three tables from the server’s own catalog and settings; the parts after them read, tune and diagnose the work autovacuum does.

VACUUM cleans up after changes, autovacuum decides when

An UPDATE or DELETE leaves the old row version in place as a dead row version, because a snapshot taken earlier may still need it, as MVCC in production: the xmin horizon shows. A plain VACUUM makes that space reusable, marks pages all-visible in the visibility map, 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. More in “Freezing and transaction ID wraparound” old rows, the subject of Freezing and transaction ID wraparound, and with ANALYZE refreshes the planner’s statistics. It shrinks the file only by cutting empty pages off its end.

Autovacuum does that work unasked. Its launcher starts a worker in each database once per autovacuum_naptime, a minute by default, so a server with four databases starts one every 15 seconds. At most autovacuum_max_workers, 3, run at a time. A worker compares each table’s counters from the cumulative statistics with that table’s thresholds and runs VACUUM, ANALYZE or both where they are exceeded. It never runs VACUUM FULL, cannot reach temporary tables and needs track_counts, which is on by default. Step through one round:

Interactive · stage by stage One round of autovacuum in one database Step through what the launcher and one worker do, with the counters and thresholds of the run's three tables.
One round of autovacuum in one database, step 7 of 7. Showing The launcher wakes, A worker starts, It reads the counters (1,500 dead, 1,500 changed), Over a threshold? (ANALYZE due, VACUUM not), VACUUM, ANALYZE or both (orders, queue, events), Counters start again. In focus: Counters start again.nothing dueThe launcher wakesevery naptime / databasesA worker startsin one databaseIt reads the counters1,500 dead, 1,500 changedOver a threshold?ANALYZE due, VACUUM notVACUUM, ANALYZE or bothorders, queue, eventsCounters start againlast_autovacuum is set

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

Every step as text
  1. The launcher wakes. The autovacuum launcher starts one worker per database in each autovacuum_naptime, a minute by default, so with four databases a new worker starts every 15 seconds. At most autovacuum_max_workers, 3 by default, run at once.
  2. A worker starts. The worker connects to its database and goes through the tables there. Temporary tables are out of its reach, and a partitioned table holds no rows of its own; its partitions are processed instead.
  3. The counters are read. For every table it reads three counters of the cumulative statistics, n_dead_tup, n_ins_since_vacuum and n_mod_since_analyze. The run's orders showed 1,500 dead and 1,500 changed rows.
  4. Compared with the thresholds. Each threshold comes from the table's reltuples and the settings, and a counter must exceed it. For 10,000 orders that meant a vacuum after 2,050 dead rows and an analyze after 1,050 changes, so only ANALYZE was due. One rule overrides the counters: once age(relfrozenxid) passes autovacuum_freeze_max_age, the table is vacuumed anyway.
  5. Nothing due here. A database where no table is over a threshold costs the worker only that check. It exits, and the database waits for the launcher's next visit.
  6. The due tables are processed. The worker processes the due tables one after another, with ANALYZE alone for the orders and both commands for the queue and the events.
  7. Counters start again. VACUUM sets n_dead_tup to the rows it could not remove, usually 0, and n_ins_since_vacuum to 0; ANALYZE starts n_mod_since_analyze again. last_autovacuum and last_autoanalyze record when.

A finished run can leave a log entry, and a running one shows its phase in pg_stat_progress_vacuum. 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. makes a worker pause after each budget of page reads and writes. A storage parameter: A setting stored with one table or index, given in CREATE TABLE … WITH (…) or ALTER TABLE … SET (…), that overrides the server-wide setting for that object, for example an autovacuum threshold., set with ALTER TABLE … SET, gives one table its own thresholds and limits.

Checkpoint

Check: What VACUUM and autovacuum do

  1. Question 1 of 2 A server holds six databases and runs autovacuum_naptime at its default of one minute. About how often does the launcher start a worker?

    Type a number.

    A hint

    The launcher spreads one visit per database over each naptime.

    Show the answer: Question 1 of 2

    Answer: 10 seconds

    The launcher tries to start one worker in each database every autovacuum_naptime, so with six databases it starts one every 60 ÷ 6 = 10 seconds; four databases gave 15 seconds in the lesson.

  2. Question 2 of 2 Which of these tables does autovacuum never vacuum or analyse?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A temporary table created by a session

    Autovacuum processes every ordinary table, partitions included, once a counter exceeds its threshold. Temporary tables are the exception the manual names.

Three thresholds decide when a table is due

Each threshold is worked out from pg_class.reltuples, the row count that the last VACUUM, ANALYZE or CREATE INDEX stored, and from the settings. A vacuum is due once the dead rows exceed this one:

vacuum at=min⁡(108, 50+0.2×reltuples)\text{vacuum at} = \min\left(10^{8},\ 50 + 0.2 \times \text{reltuples}\right)

That is 50 rows, autovacuum_vacuum_threshold, plus 20 % of the table, autovacuum_vacuum_scale_factor, and at most autovacuum_vacuum_max_threshold, the cap new in PostgreSQL 18: 2,050 for 10,000 rows. Inserted rows have a rule of their own, so that a table that only grows is vacuumed too, which sets its pages all-visible and can freeze rows early:

insert vacuum at=1000+0.2×reltuples×(1−relallfrozenrelpages)\text{insert vacuum at} = 1000 + 0.2 \times \text{reltuples} \times \left(1 - \frac{\text{relallfrozen}}{\text{relpages}}\right)

The last factor, new in PostgreSQL 18, is the share of pages not yet all-frozen; version 17 took 20 % of every row. For 10,000 rows with half their pages frozen it gives 1,000 + 1,000 = 2,000 inserts. An analyze is due once inserted, updated and deleted rows together exceed 50 + 10 % of the table, and a table that no VACUUM, ANALYZE or CREATE INDEX has measured yet counts as 0 rows. The run vacuums three tables once, gives them a day of work and computes the thresholds as autovacuum does:

Three tables measured against their autovacuum thresholds

thresholds_session.py

"""Autovacuum thresholds on PostgreSQL 18.6: day.sql creates three tables, vacuums them once and gives them a day of
work; thresholds.sql then computes, for each table, the three thresholds that autovacuum compares with its counters,
from pg_class, pg_stat_user_tables and the server's settings, and the capped vacuum threshold of bigger tables.

day.sql runs with psql -q -A -t, which prints neither its statements nor their command tags (the file is the tab
beside this one), so the record shows the computation and its results."""

from pg_session import Server

# Autovacuum is off for this run, so no worker resets the counters the query reads: every number is the one the
# statements left, and two runs print the same. The thresholds themselves come from the server's default settings.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off", "timezone": "'UTC'"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("day.sql", flags=("-q", "-A", "-t"))
    pg.psql("thresholds.sql")

Output

$ psql -X -q -A -t -f day.sql

$ psql -X -a -f thresholds.sql
-- What the first VACUUM stored for each table: the inputs of its thresholds.
SELECT relname, reltuples, relpages, relallfrozen FROM pg_class
WHERE relname IN ('orders', 'events', 'queue') ORDER BY relname;
 relname | reltuples | relpages | relallfrozen 
---------+-----------+----------+--------------
 events  |     10000 |       64 |           64
 orders  |     10000 |       64 |            0
 queue   |       100 |        1 |            0
(3 rows)

-- The three thresholds as autovacuum works them out (none of these settings is -1 here), beside the counters.
SELECT st.relname AS "table", n_dead_tup AS dead, vacuum_at, n_ins_since_vacuum AS inserted, insert_at,
       n_mod_since_analyze AS changed, analyze_at,
       concat_ws(', ', CASE WHEN n_dead_tup > vacuum_at OR n_ins_since_vacuum > insert_at THEN 'VACUUM' END,
                 CASE WHEN n_mod_since_analyze > analyze_at THEN 'ANALYZE' END) AS due
FROM pg_stat_user_tables AS st JOIN pg_class AS c ON c.oid = st.relid,
     LATERAL (SELECT greatest(reltuples, 0) AS n,
                     CASE WHEN relpages > 0 AND relallfrozen > 0
                          THEN 1 - least(relallfrozen, relpages)::float8 / relpages ELSE 1 END AS unfrozen) AS r,
     LATERAL (SELECT round(least(current_setting('autovacuum_vacuum_max_threshold')::float8,
                                 current_setting('autovacuum_vacuum_threshold')::float8
                                 + current_setting('autovacuum_vacuum_scale_factor')::float8 * n)) AS vacuum_at,
                     round(current_setting('autovacuum_vacuum_insert_threshold')::float8
                           + current_setting('autovacuum_vacuum_insert_scale_factor')::float8 * n * unfrozen) AS insert_at,
                     round(current_setting('autovacuum_analyze_threshold')::float8
                           + current_setting('autovacuum_analyze_scale_factor')::float8 * n) AS analyze_at) AS t
ORDER BY st.relname;
 table  | dead | vacuum_at | inserted | insert_at | changed | analyze_at |       due       
--------+------+-----------+----------+-----------+---------+------------+-----------------
 events |    0 |      2050 |     2000 |      1000 |    2000 |       1050 | VACUUM, ANALYZE
 orders | 1500 |      2050 |        0 |      3000 |    1500 |       1050 | ANALYZE
 queue  |  500 |        70 |      500 |      1020 |    1000 |         60 | VACUUM, ANALYZE
(3 rows)

-- The vacuum rule for bigger tables: PostgreSQL 18 caps it at autovacuum_vacuum_max_threshold.
SELECT n AS reltuples, round(base + scale * n) AS without_cap, round(least(cap, base + scale * n)) AS vacuum_at
FROM (VALUES (10000), (1000000), (100000000), (1000000000)) AS sizes (n),
     LATERAL (SELECT current_setting('autovacuum_vacuum_threshold')::numeric AS base,
                     current_setting('autovacuum_vacuum_scale_factor')::numeric AS scale,
                     current_setting('autovacuum_vacuum_max_threshold')::numeric AS cap) AS s;
 reltuples  | without_cap | vacuum_at 
------------+-------------+-----------
      10000 |        2050 |      2050
    1000000 |      200050 |    200050
  100000000 |    20000050 |  20000050
 1000000000 |   200000050 | 100000000
(4 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 thresholds_session.py

thresholds.sql

-- What the first VACUUM stored for each table: the inputs of its thresholds.
SELECT relname, reltuples, relpages, relallfrozen FROM pg_class
WHERE relname IN ('orders', 'events', 'queue') ORDER BY relname;
-- The three thresholds as autovacuum works them out (none of these settings is -1 here), beside the counters.
SELECT st.relname AS "table", n_dead_tup AS dead, vacuum_at, n_ins_since_vacuum AS inserted, insert_at,
       n_mod_since_analyze AS changed, analyze_at,
       concat_ws(', ', CASE WHEN n_dead_tup > vacuum_at OR n_ins_since_vacuum > insert_at THEN 'VACUUM' END,
                 CASE WHEN n_mod_since_analyze > analyze_at THEN 'ANALYZE' END) AS due
FROM pg_stat_user_tables AS st JOIN pg_class AS c ON c.oid = st.relid,
     LATERAL (SELECT greatest(reltuples, 0) AS n,
                     CASE WHEN relpages > 0 AND relallfrozen > 0
                          THEN 1 - least(relallfrozen, relpages)::float8 / relpages ELSE 1 END AS unfrozen) AS r,
     LATERAL (SELECT round(least(current_setting('autovacuum_vacuum_max_threshold')::float8,
                                 current_setting('autovacuum_vacuum_threshold')::float8
                                 + current_setting('autovacuum_vacuum_scale_factor')::float8 * n)) AS vacuum_at,
                     round(current_setting('autovacuum_vacuum_insert_threshold')::float8
                           + current_setting('autovacuum_vacuum_insert_scale_factor')::float8 * n * unfrozen) AS insert_at,
                     round(current_setting('autovacuum_analyze_threshold')::float8
                           + current_setting('autovacuum_analyze_scale_factor')::float8 * n) AS analyze_at) AS t
ORDER BY st.relname;
-- The vacuum rule for bigger tables: PostgreSQL 18 caps it at autovacuum_vacuum_max_threshold.
SELECT n AS reltuples, round(base + scale * n) AS without_cap, round(least(cap, base + scale * n)) AS vacuum_at
FROM (VALUES (10000), (1000000), (100000000), (1000000000)) AS sizes (n),
     LATERAL (SELECT current_setting('autovacuum_vacuum_threshold')::numeric AS base,
                     current_setting('autovacuum_vacuum_scale_factor')::numeric AS scale,
                     current_setting('autovacuum_vacuum_max_threshold')::numeric AS cap) AS s;

day.sql

-- Three tables with three workloads: orders are updated, events are only appended, the queue is emptied and refilled.
CREATE TABLE orders (id int PRIMARY KEY, status text NOT NULL, note text NOT NULL);
INSERT INTO orders SELECT n, 'placed', 'order ' || n FROM generate_series(1, 10000) AS n;
CREATE TABLE events (id int PRIMARY KEY, kind int NOT NULL, payload text NOT NULL);
INSERT INTO events SELECT n, n % 7, 'event ' || n FROM generate_series(1, 10000) AS n;
CREATE TABLE queue (id int PRIMARY KEY, job text NOT NULL);
INSERT INTO queue SELECT n, 'job ' || n FROM generate_series(1, 100) AS n;
-- The session's counts reach the statistics system now; then a first VACUUM resets them and stores reltuples,
-- relpages and relallfrozen in pg_class. The append-only events are frozen as well.
SELECT pg_stat_force_next_flush();
VACUUM (ANALYZE) orders, queue;
VACUUM (FREEZE, ANALYZE) events;
-- A day's work: 1,500 orders shipped, 2,000 events appended, the queue's 100 jobs taken and replaced five times.
UPDATE orders SET status = 'shipped' WHERE id <= 1500;
INSERT INTO events SELECT n, n % 7, 'event ' || n FROM generate_series(10001, 12000) AS n;
DELETE FROM queue;
INSERT INTO queue SELECT n, 'job ' || n FROM generate_series(101, 200) AS n;
DELETE FROM queue;
INSERT INTO queue SELECT n, 'job ' || n FROM generate_series(201, 300) AS n;
DELETE FROM queue;
INSERT INTO queue SELECT n, 'job ' || n FROM generate_series(301, 400) AS n;
DELETE FROM queue;
INSERT INTO queue SELECT n, 'job ' || n FROM generate_series(401, 500) AS n;
DELETE FROM queue;
INSERT INTO queue SELECT n, 'job ' || n FROM generate_series(501, 600) AS n;
SELECT pg_stat_force_next_flush();

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)

The orders, 1,500 dead rows against 2,050, were due only for ANALYZE, 1,500 changes against 1,050; the queue, 500 dead rows against 70, for both. The events had no dead rows and still needed VACUUM: VACUUM (FREEZE) had left every page all-frozen, so 2,000 inserts beat a threshold of 1,000 where PostgreSQL 17 would have waited for 3,000. The last query shows the cap turning 200,000,050 into 100,000,000.

Key idea

A table is vacuumed when its dead rows exceed 50 + 20 % of its rows, at most 100 million, or its inserts exceed 1,000 + 20 % of its unfrozen rows; it is analysed when its changes exceed 50 + 10 %.

Checkpoint

Check: The vacuum, insert and analyze thresholds

  1. Question 1 of 2 A table has reltuples 250,000 and the default settings. Over how many dead rows does autovacuum vacuum it?

    Type a number.

    A hint

    Base threshold plus scale factor times reltuples, then the cap.

    Show the answer: Question 1 of 2

    Answer: 50050 dead rows

    50 + 0.2 × 250,000 = 50,050, far below the cap of 100,000,000, so the vacuum is due once the dead rows exceed 50,050.

  2. Question 2 of 2 On PostgreSQL 18 with the defaults, a table of 40,000 rows whose pages are all all-frozen receives 1,500 inserts and nothing else. Which commands are due?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: VACUUM only

    PostgreSQL 18 scales the insert rule by the share of pages not yet all-frozen. With every page frozen only the base threshold of 1,000 is left, as with the frozen events of the run.

Practise: the next trigger, worked out

Each question gives a table’s numbers. Work out the threshold before you compare it with the counter, as the query of the run does.

Practice that adapts to you

Practise this lesson

8 questions about 9 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 does a plain VACUUM do? Choose every one that applies.

    Choose every answer that is right.

    Show the answer: Question 1 of 8

    Answer:

    • Marks pages all-visible in the visibility map
    • Makes the space of dead row versions reusable

    A plain VACUUM cleans up in place, updates the visibility map, freezes old rows and, with ANALYZE, refreshes the statistics; it shrinks the file only by its empty last pages.

  2. Question 2 of 8 A server has one database and the default naptime. A table crosses its threshold just after a worker finished that database. About how long until a worker looks at the table again?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: About a minute, at the database's next visit

    A table may wait up to about one naptime after crossing, longer when every worker is busy elsewhere.

  3. Question 3 of 8 A server is configured with track_counts = off to save a little work. What happens to autovacuum's rounds?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: Workers have no counters to compare, so no table becomes due by its thresholds

    The manual says autovacuum cannot be used unless track_counts is on, which is its default.

  4. Question 4 of 8 With the default settings, over how many inserted, updated and deleted rows is a table with reltuples 10,000 analysed?

    Type a number.

    Show the answer: Question 4 of 8

    Answer: 1050 changed rows

    50 + 0.1 × 10,000 = 1,050, the analyze_at of the orders in the run; their 1,500 updates exceeded it.

  5. Question 5 of 8 A table has reltuples 800,000,000 and the default settings of PostgreSQL 18. Over how many dead rows is it vacuumed?

    Type a number.

    A hint

    Work out the formula first, then compare it with autovacuum_vacuum_max_threshold.

    Show the answer: Question 5 of 8

    Answer: 100000000 dead rows

    50 + 0.2 × 800,000,000 = 160,000,050, above the cap of 100,000,000, so the cap decides.

  6. Question 6 of 8 A table is created and loaded with 50,000 rows, and no VACUUM, ANALYZE or CREATE INDEX has run on it since the load. With the defaults, what does the next worker run on it?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: VACUUM and ANALYZE

    reltuples stays -1 until a VACUUM, an ANALYZE or a CREATE INDEX on the filled table stores a row count, and autovacuum takes -1 as 0, so a freshly loaded table is due for both on the next round.

  7. Question 7 of 8 An append-only table has reltuples 1,000,000, and 90 % of its pages are all-frozen. On PostgreSQL 18 with the defaults, over how many inserts is it vacuumed?

    Type a number.

    Show the answer: Question 7 of 8

    Answer: 21000 inserted rows

    1,000 + 0.2 × 1,000,000 × (1 − 0.9) = 1,000 + 20,000 = 21,000. PostgreSQL 17 would have waited for 1,000 + 200,000 = 201,000 inserts.

  8. Question 8 of 8 A table with reltuples 5,000 and no frozen pages has 900 dead rows, 300 inserts since its last vacuum and 1,200 changes since its last analyze. Which commands are due with the defaults?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: ANALYZE only

    The analyze threshold uses 10 % instead of 20 %, so a table often needs ANALYZE before VACUUM, as the orders of the run did.

Reading what autovacuum did: its log entry and the progress view Pro: opens with a Pro, Premium or Ultimate pass

Autovacuum works out of sight, so its log entry and its progress view are how you see what it did. This part records one automatic vacuum on PostgreSQL 18.6 and reads its entry line by line, then watches a throttled worker from another session until a command from that session cancels it.

In this part:

  • One automatic vacuum, its log entry line by line
  • A throttled worker in the progress view, then cancelled

1 diagram · 2 runnable examples (after.sql, autovacuum.sql, autovacuum_session.py, cancel.sql, finished.sql, throttle.sql, throttle_session.py, watch.sql) · 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).

Tuning the cost budget, the workers and single tables, with numbers Pro: opens with a Pro, Premium or Ultimate pass

Autovacuum’s speed is set by a cost budget, and its reach by the number of workers and each table’s thresholds. This part computes the budget in pages a second from the server’s settings, shows what more workers do to it, and gives a large table and a small queue storage parameters of their own.

In this part:

  • The cost budget in pages a second, with numbers
  • More workers or a higher cost limit, decided with numbers
  • Storage parameters for the tables that need them

2 runnable examples (av_setting.sql, block.sql, budget.sql, budget_session.py, load.sql, pertable.sql, pertable_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).

When autovacuum falls behind: the signs and the first change Pro: opens with a Pro, Premium or Ultimate pass

Autovacuum that cannot keep up shows itself in a few views and log lines long before it shows as bloat. This part lists those signs with the place each one shows, then puts the changes in the order in which to try them.

In this part:

  • Five signs that autovacuum is falling behind, and where each shows
  • What to change first, and why in that order

1 diagram · 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).

Interview questions

Warm-up, fresher to 2–5 years: when does autovacuum process a table, and why can it analyse one without vacuuming it?

A worker compares three counters per table with thresholds from pg_class.reltuples and the settings. It vacuums when dead rows exceed 50 + 0.2 × reltuples, at most 100 million in PostgreSQL 18, or when inserts since the last vacuum exceed 1,000 + 0.2 × reltuples × the share of pages not all-frozen. It analyses when inserted, updated and deleted rows since the last ANALYZE exceed 50 + 0.1 × reltuples. That threshold is the lower one, so a table can be due for ANALYZE alone: 1,500 updates on 10,000 rows crossed 1,050 but not 2,050. A worker visits each database once per autovacuum_naptime, a minute by default, so a table may wait about that long after crossing.

Three harder cases, with their first steps, are in the module’s bank:

  1. Mid to senior

    An orders table on PostgreSQL 18 has 400 million rows, about 120 GiB, and the server's default autovacuum settings. Its queries get slower for days at a time, and every automatic vacuum of it runs for hours. Explain when autovacuum starts on it, why the runs take so long, and what you would 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

    On a PostgreSQL 18 server with about 40 busy tables, dead rows grow from week to week on many of them, and pg_stat_activity shows three autovacuum workers at almost any moment. A colleague wants to raise autovacuum_max_workers to 10. What do you check before you agree, and what would you change?

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A sessions table is far over its vacuum threshold, yet its last_autovacuum has not changed for two days. The server log shows 'canceling autovacuum task' for it several times an hour. What is cancelling the vacuums, how do you find the job behind it, and how do you get the table vacuumed?

    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. What does VACUUM do, and what does autovacuum decide?

    VACUUM makes dead-version space reusable, marks pages all-visible, freezes old rows and (with ANALYZE) refreshes statistics; it only cuts empty trailing pages. Autovacuum runs VACUUM/ANALYZE itself when a table's change counters exceed its thresholds.

  2. How does the autovacuum launcher schedule work?

    It starts a worker in each database once per autovacuum_naptime (1 minute), with at most autovacuum_max_workers (3) running at a time. It never runs VACUUM FULL, cannot reach temporary tables, and needs track_counts (on by default).

  3. When is a table due for autovacuum (dead rows)?

    When dead rows exceed min(10^8, 50 + 0.2 x reltuples): autovacuum_vacuum_threshold (50) plus scale factor (20%), capped by autovacuum_vacuum_max_threshold (new in PostgreSQL 18; 2,050 for 10,000 rows).

  4. What triggers an insert-vacuum and an analyze?

    An insert-vacuum is due at 1000 + 0.2 x reltuples x (unfrozen share of pages) — PostgreSQL 18 adds the unfrozen factor, where PostgreSQL 17 took 20% of every row. An analyze is due once inserted, updated and deleted rows together exceed 50 + 10% of the table.

Key takeaways

  • VACUUM makes dead row versions’ space reusable, updates the visibility map and freezes old rows; autovacuum runs it and ANALYZE, with a worker in each database every autovacuum_naptime.
  • A vacuum is due when dead rows exceed 50 + 20 % of reltuples, at most 100,000,000 since PostgreSQL 18, and an analyze when changes exceed 50 + 10 %, so the orders needed only ANALYZE.
  • Inserts trigger a vacuum at 1,000 + 20 % of the unfrozen rows, so the frozen events were due with no dead rows.

Terms in this lesson

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.
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.
storage parameter
A setting stored with one table or index, given in CREATE TABLE … WITH (…) or ALTER TABLE … SET (…), that overrides the server-wide setting for that object, for example an autovacuum threshold.

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.