Background processes
Autovacuum launcher and workers
Autovacuum is a team: one launcher process that decides which database is due, and up to autovacuum_max_workers worker processes that vacuum and analyze the tables that need it.
In plain words
Autovacuum is not one process but a small team. The launcher is a scheduler: it never vacuums anything itself. It keeps a list of databases and, every autovacuum_naptime divided by the number of databases, picks the next one and asks the postmaster to start a worker for it.
A worker connects to that one database, checks which tables have collected enough dead rows (or changes) to pass their threshold, and vacuums or analyzes them one after another. When nothing is left, it exits.
At most autovacuum_max_workers workers run at once (3 by default), and together they share one I/O budget, so adding workers does not make vacuum faster overall: each one just gets a smaller share.
Normally a worker politely gives up if someone needs a conflicting lock on its table. The exception is a vacuum to prevent wraparound: that one must finish and will not step aside.
For DBAs and operators
The launcher
One process (postgres: autovacuum launcher, wait_event AutovacuumMain). It builds a schedule so that each database gets a worker roughly every autovacuum_naptime (1min): with 20 databases, it starts one every 3 seconds. Databases at risk of transaction ID or multixact wraparound are served first. It cannot start workers itself; it signals the postmaster, which forks them. If all worker slots are busy, a due database simply waits, which is how large clusters with many busy databases fall behind.
Workers and slots
Each worker is postgres: autovacuum worker <dbname> with backend_type = 'autovacuum worker' and a query like autovacuum: VACUUM ANALYZE public.big. New in PostgreSQL 18: autovacuum_worker_slots (restart, default 16) reserves the process slots, while autovacuum_max_workers can now be changed with a reload, up to that number.
A worker reads the per-table counters from the cumulative statistics (n_dead_tup, n_ins_since_vacuum, n_mod_since_analyze), compares them with the thresholds explained in Autovacuum, and processes the qualifying tables. It rechecks each table just before starting it, so two workers in the same database do not vacuum the same table.
Shared cost budget
autovacuum_vacuum_cost_limit (-1 means use vacuum_cost_limit, 200) is divided among all running workers that use the default cost settings; each sleeps autovacuum_vacuum_cost_delay (2ms) when it has spent its share (wait_event VacuumDelay). Tables with their own cost settings are not balanced. To make autovacuum faster, raise the cost limit, not only the number of workers. PostgreSQL 18 adds track_cost_delay_timing to measure the sleeping in pg_stat_progress_vacuum.delay_time.
Being cancelled
When a normal autovacuum blocks someone for deadlock_timeout (for example ALTER TABLE, LOCK TABLE, TRUNCATE), the waiting backend cancels it. The worker logs this and continues with its next table:
A worker started because age(relfrozenxid) passed autovacuum_freeze_max_age shows (to prevent wraparound) in its query and is not auto-cancelled. DDL then queues behind it, and everything else on the table queues behind the DDL. Do not kill it repeatedly: it will just start again. Let it finish, or run a manual VACUUM (FREEZE) during a quiet period.
Watching it
SELECT pid, datname, wait_event, now() - xact_start AS running, query
FROM pg_stat_activity
WHERE backend_type = 'autovacuum worker';
SELECT p.pid, p.relid::regclass, p.phase,
p.heap_blks_scanned, p.heap_blks_total, p.delay_time
FROM pg_stat_progress_vacuum p;With log_autovacuum_min_duration (default 10min since PostgreSQL 15; set it to 0 to log every run) each run logs LOG: automatic vacuum of table "postgres.public.t": index scans: 0 followed by pages, tuples, buffer usage and WAL usage details.
If they die
A worker whose current table is cancelled (pg_cancel_backend() or a lock conflict) moves on to its next table; one terminated with pg_terminate_backend() just exits (FATAL: terminating autovacuum process due to administrator command). Killing the launcher or a worker with signal 9 causes a crash restart, which also resets the statistics autovacuum relies on.
PostgreSQL 18 source trail: autovacuum.c documents launcher/worker roles and balancing of cost-delay settings.
For developers: inside the source
Shared memory
Both roles live in autovacuum.c. AutoVacuumShmemStruct holds av_freeWorkers (a dclist of free WorkerInfoData slots, sized by autovacuum_worker_slots), av_runningWorkers, av_startingWorker, the av_workItems[] array and the atomic av_nworkersForBalance. A WorkerInfoData records wi_dboid, wi_tableoid, wi_proc, wi_launchtime, wi_dobalance and wi_sharedrel. Most fields are protected by AutovacuumLock; wi_tableoid / wi_sharedrel by AutovacuumScheduleLock.
Launcher call flow
AutoVacLauncherMain()keepsDatabaseList(avl_dbaseentries with next-worker times), rebuilt byrebuild_database_list().launcher_determine_sleep()computes the nap; it waits withWAIT_EVENT_AUTOVACUUM_MAIN.launch_worker()→do_start_worker()picks a database (wraparound-endangered first), pops a slot fromav_freeWorkersintoav_startingWorkerand callsSendPostmasterSignal(PMSIGNAL_START_AUTOVAC_WORKER).- The postmaster forks the worker (
StartAutovacuumWorker()); if that fails it signals the launcher back (AutoVacWorkerFailed(), SIGUSR2).
Worker call flow
AutoVacWorkerMain()takesav_startingWorker, connects towi_dboidand callsdo_autovacuum().do_autovacuum()scanspg_class; for each table it readspgstat_fetch_stat_tabentry_ext()and asksrelation_needs_vacanalyze()(thresholds, freeze age) to build a list.- For each candidate, under
AutovacuumScheduleLock, it skips tables in other workers'wi_tableoid, thentable_recheck_autovac()re-reads stats to avoid redundant work. autovacuum_do_vac_analyze()→vacuum(), the same code as manual VACUUM.- Pending work items (BRIN summarisation via
AutoVacuumRequestWork()) run inperform_work_item().
Cost balancing and cancellation
autovac_recalculate_workers_for_balance() counts workers with wi_dobalance set and stores the result in av_nworkersForBalance; AutoVacuumUpdateCostLimit() divides the cost limit by that number, and VacuumUpdateCosts() reapplies it after a reload. A worker sets PROC_IS_AUTOVACUUM in its statusFlags (plus PROC_VACUUM_FOR_WRAPAROUND for anti-wraparound runs). When a lock waiter's deadlock check finds it is blocked by an autovacuum (GetBlockingAutoVacuumPgproc() in deadlock.c), ProcSleep() in proc.c sends SIGINT, unless the wraparound flag is set.
Experimenting
src/test/modules/xid_wraparoundconsumes XIDs quickly to test anti-wraparound autovacuum.log_min_messages = debug3makesrelation_needs_vacanalyze()log each table as<table>: vac: … (threshold …), ins: …, anl: …;debug1logsautovacuum: processing database …;pg_stat_progress_vacuumshows the worker's phase.pg_visibilityto see what a run set in the visibility map.
Parameters that shape this
autovacuum autovacuum_max_workers autovacuum_worker_slots autovacuum_naptime autovacuum_vacuum_cost_limit autovacuum_vacuum_cost_delay autovacuum_freeze_max_age log_autovacuum_min_duration autovacuum_work_mem track_counts
Related topics
Autovacuum: when tables get vacuumed VACUUM XID wraparound and freezing Cumulative statistics Postmaster Locking and deadlocks ANALYZE and statistics