Concepts covered
PostgreSQL 19 autovacuum - parallel workers and the scoring system
Autovacuum has two jobs: keep dead tuples from accumulating, and keep every table’s relfrozenxid
young enough that the cluster never approaches transaction ID wraparound. Before 19 it had a blind
spot in each. Table order inside a database was effectively pg_class order - no notion of which
table was most urgent. And a vacuum’s index cleanup ran serially even though manual VACUUM had
supported a PARALLEL option since 13. PostgreSQL 19 fixes both.
The scoring system
The launcher still picks a database first - the one at risk of wraparound, else the least recently processed - but inside the database the worker now builds the list of tables needing vacuum or analyse and sorts them by a score: the maximum of five component scores, each comparing a measurement against its threshold.
| Component | Measures | Weight GUC (default 1.0) |
|---|---|---|
| Transaction ID | relfrozenxid age vs autovacuum_freeze_max_age; grows sharply past vacuum_failsafe_age |
autovacuum_freeze_score_weight |
| Multixact ID | relminmxid age vs autovacuum_multixact_freeze_max_age; grows sharply past vacuum_multixact_failsafe_age or around two billion member entries |
autovacuum_multixact_freeze_score_weight |
| Vacuum | updated/deleted tuples vs autovacuum_vacuum_threshold + scale factor (capped by autovacuum_vacuum_max_threshold) |
autovacuum_vacuum_score_weight |
| Vacuum insert | inserted tuples vs autovacuum_vacuum_insert_threshold + insert scale factor |
autovacuum_vacuum_insert_score_weight |
| Analyse | changed tuples vs autovacuum_analyze_threshold + scale factor |
autovacuum_analyze_score_weight |
Each weight multiplies its component, so autovacuum_analyze_score_weight = 2.0 doubles the analyse
component’s score. Two non-obvious behaviours are in the documentation: for the two freeze
components, a weight above 1.0 also lowers the age at which that component starts scaling
aggressively (the scaling age is divided by the weight); and setting all five weights to 0.0
reverts to the pre-19 behaviour of plain pg_class order.
-- watch what autovacuum thinks is urgent, in the current database:
SELECT * FROM pg_stat_autovacuum_scores;
pg_stat_autovacuum_scores is the diagnostic this feature ships with - it shows the current score
of every table in the current database, which is the answer to “why is autovacuum ignoring my hot
table” that previously had no answer.
A worked score
Take three tables in one database with default settings (autovacuum_freeze_max_age 200 million,
autovacuum_vacuum_threshold 50 + 20% scale factor, all five weights at 1.0):
| Table | relfrozenxid age | Dead tuples vs threshold | Score |
|---|---|---|---|
audit_log |
180 million (90% of freeze max) | 4k dead vs 50k threshold (~0.08) | ~0.9 - freeze dominates |
sessions |
40 million (20%) | 30k dead vs 30k threshold (~1.0) | ~1.0 - vacuum dominates |
ref_countries |
10 million | 200 dead | ~0.05 - nothing urgent |
sessions vacuums first despite audit_log being closer to wraparound in absolute terms: scoring
normalises each component against its own threshold and takes the maximum, so a table over a
regular threshold outranks one merely approaching freeze age - until freeze age passes the
failsafe point, where the freeze component grows sharply. Tuning then reads like this: a hot
append-mostly table that keeps losing the queue gets autovacuum_vacuum_insert_score_weight
raised server-wide only if many insert-heavy tables share the problem; for one table the honest
fix is still lowering its autovacuum_vacuum_insert_scale_factor storage parameter, because the
weights move every table at once.
Text equivalent
- Each table’s score is the max of freeze, multixact-freeze, vacuum, vacuum-insert and analyse components, each weighted by its GUC.
- The launcher works the database with the highest-priority table first.
- For the chosen table, qualifying indexes (over
min_parallel_index_scan_size) are vacuumed by parallel workers, one worker per index, capped byautovacuum_max_parallel_workersand the sharedmax_parallel_workerspool.
Parallel index vacuuming
Manual VACUUM (PARALLEL n) could already run index vacuum and index cleanup with background
workers; in 19 autovacuum can do the same:
| Setting | Scope | Default |
|---|---|---|
autovacuum_max_parallel_workers |
GUC - cap per autovacuum worker | 0 (disabled) |
autovacuum_parallel_workers |
per-table storage parameter | unset |
Enable it globally with autovacuum_max_parallel_workers, and steer per table with the storage
parameter. The caveats are straight from the parallel vacuum rules: an index participates only if
larger than min_parallel_index_scan_size, one worker per index, so parallelism needs at least two
qualifying indexes; and the calculated worker count is not guaranteed - a vacuum may run with fewer
or none. Workers also inherit the leader’s cost delay settings and come out of the same
max_parallel_workers budget as everything else.
ALTER TABLE events SET (autovacuum_parallel_workers = 4);
-- postgresql.conf:
-- autovacuum_max_parallel_workers = 4
The trade-off: index phases finish sooner, but each worker draws on the shared worker pool and the same I/O budget, so on a small instance a parallel autovacuum can compete with query parallelism rather than complement it.
Monitoring changes around it
pg_stat_progress_vacuumgainsstarted_by(who launched the vacuum, includingautovacuum_wraparoundfor anti-wraparound runs) andmode(its aggressiveness);pg_stat_progress_analyzegains the samestarted_bycolumn.log_autovacuum_min_durationnow covers only vacuum operations; a separatelog_autoanalyze_min_durationlogs long-running autovacuum-triggered analyses, so you can watch analyse without vacuum noise or vice versa.- The wraparound warning fires at 100 million transactions remaining, up from 40 million - earlier warning, same failure.
VACUUM (VERBOSE)and autovacuum logs now report memory usage and parallelism.- Separately but related: query table scans can now mark pages all-visible in the visibility map,
a job previously reserved to
VACUUMandCOPY ... FREEZE. Reads now help future index-only scans - verify the bits withpg_visibility, covered on maintenance extensions.
Manual vacuuming: vacuumdb and index I/O
Two changes on the manual side of routine vacuuming, plus a performance one under the hood:
vacuumdb --dry-runprints theVACUUM/ANALYZEcommands it would run without running them - the safe way to check what a scheduled run would touch.vacuumdb --analyze-onlyand--analyze-in-stagesnow include partitioned tables when no targets are given, matchingANALYZE; previously they silently skipped them.- Hash index bulk-deletion and
GINindex vacuuming switched to streaming reads - less I/O latency on the index-cleanup phase for those index types.
What changed, and in which release
| Behaviour | PG 18 and earlier | PG 19 |
|---|---|---|
| Table processing order | pg_class order |
Scored: most urgent first |
| Autovacuum index parallelism | Serial only | autovacuum_max_parallel_workers + per-table autovacuum_parallel_workers |
| Why-this-table visibility | None | pg_stat_autovacuum_scores |
| Vacuum initiator in progress view | Not reported | started_by / mode in pg_stat_progress_vacuum |
| Analyse logging | Shared log_autovacuum_min_duration |
Split into log_autoanalyze_min_duration |
| Wraparound warning threshold | 40 million transactions | 100 million |
| All-visible marking | VACUUM and COPY FREEZE only | Ordinary table scans too |
| vacuumdb dry-run | Not available | --dry-run prints commands without running them |
| vacuumdb analyse of partitioned tables | Skipped by --analyze-only/--analyze-in-stages |
Included, matching ANALYZE |
Where it breaks
- Weights set unevenly. Raising
autovacuum_freeze_score_weightdoes not just rank freeze urgency higher, it lowers the age where freeze scoring turns aggressive. Read the component semantics inpg_stat_autovacuum_scoresbefore tuning by feel. - Expecting workers that never come.
autovacuum_max_parallel_workersis a cap, not a promise - undermax_parallel_workerscontention or with fewer than two large indexes, vacuum stays serial. - Disabled autovacuum still cannot stop wraparound vacuums. Unchanged from earlier versions:
autovacuum = offdoes not prevent anti-wraparound workers, andstarted_by = autovacuum_wraparoundinpg_stat_progress_vacuumis how you see them. - Beta caveat. The GUC names and defaults are from the 19 beta documentation and may still change before GA.
What this is not for
- A bloated table that already needs surgery - scoring decides order, not capability; a table
that has outgrown vacuum still needs REPACK or
VACUUM FULL. - Per-table thresholds. The existing storage parameters (
autovacuum_vacuum_scale_factorand friends) remain the right tool for a single hot table; scoring changes which table goes first, not when each qualifies.
PostgreSQL release support
5 supported| Release | Latest | Released | End of life | Status |
|---|---|---|---|---|
18 | 18.6 | Sept 2025 | Nov 2030 | Supported |
17 | 17.11 | Sept 2024 | Nov 2029 | Supported |
16 | 16.15 | Sept 2023 | Nov 2028 | Supported |
15 | 15.19 | Oct 2022 | Nov 2027 | Supported |
14 | 14.24 | Sept 2021 | Nov 2026 | Supported |
13 | 13.23 | Sept 2020 | Nov 2025 | End of life |
12 | 12.22 | Oct 2019 | Nov 2024 | End of life |
11 | 11.22 | Oct 2018 | Nov 2023 | End of life |
Data as of 2026-10-01 from endoflife.date. Refresh with node scripts/fetch-versions.mjs.
What to watch
Specific things that would change what this page concludes.
- Whether the five score weight GUCs keep their names and defaults through to GA
- How parallel autovacuum interacts with autovacuum_max_workers on real clusters - parallel workers come from the shared worker pool
Where PostgreSQL sits
How this component relates to the rest of the stack. Items without a link are not covered here yet.