IntermediateReferenceDatabases

PostgreSQL 19 autovacuum - parallel workers and the scoring system

What PostgreSQL 19 changes in autovacuum - parallel index vacuuming controlled by autovacuum_max_parallel_workers, a five-component scoring system that decides which tables get vacuumed first, and the new monitoring surface around it.

Applies to
PostgreSQL19PostgreSQL 19 was still in beta at the last review (Beta 4, 24 September 2026 is the latest pre-release) - the defaults and GUC names here are from the beta documentation and can still shift before GA. Threshold mechanics described for comparison apply to 14-18 as marked.
emergingUpdated Reviewed
intermediateAssumes working familiarity; skips the fundamentals.

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.

Figure 1. Scores in, workers out
Figure 1. Scores in, workers outTables carry different urgency scores; the launcher picks the highest score first and dispatches parallel index workers, one per qualifying index.audit_logscore 0.9sessionsscore 0.5ref_countriesscore 0.05launcherpicks highestaudit_logwinnerworker 1worker 2worker 3indexesone per worker123
The launcher sorts by the maximum of five weighted component scores; the winning table's indexes are vacuumed in parallel, one worker per index over min_parallel_index_scan_size.
Text equivalent
  1. Each table’s score is the max of freeze, multixact-freeze, vacuum, vacuum-insert and analyse components, each weighted by its GUC.
  2. The launcher works the database with the highest-priority table first.
  3. For the chosen table, qualifying indexes (over min_parallel_index_scan_size) are vacuumed by parallel workers, one worker per index, capped by autovacuum_max_parallel_workers and the shared max_parallel_workers pool.

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_vacuum gains started_by (who launched the vacuum, including autovacuum_wraparound for anti-wraparound runs) and mode (its aggressiveness); pg_stat_progress_analyze gains the same started_by column.
  • log_autovacuum_min_duration now covers only vacuum operations; a separate log_autoanalyze_min_duration logs 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 VACUUM and COPY ... FREEZE. Reads now help future index-only scans - verify the bits with pg_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-run prints the VACUUM/ANALYZE commands it would run without running them - the safe way to check what a scheduled run would touch.
  • vacuumdb --analyze-only and --analyze-in-stages now include partitioned tables when no targets are given, matching ANALYZE; previously they silently skipped them.
  • Hash index bulk-deletion and GIN index 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_weight does not just rank freeze urgency higher, it lowers the age where freeze scoring turns aggressive. Read the component semantics in pg_stat_autovacuum_scores before tuning by feel.
  • Expecting workers that never come. autovacuum_max_parallel_workers is a cap, not a promise - under max_parallel_workers contention or with fewer than two large indexes, vacuum stays serial.
  • Disabled autovacuum still cannot stop wraparound vacuums. Unchanged from earlier versions: autovacuum = off does not prevent anti-wraparound workers, and started_by = autovacuum_wraparound in pg_stat_progress_vacuum is 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_factor and 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
ReleaseLatestReleasedEnd of lifeStatus
1818.6Sept 2025Nov 2030Supported
1717.11Sept 2024Nov 2029Supported
1616.15Sept 2023Nov 2028Supported
1515.19Oct 2022Nov 2027Supported
1414.24Sept 2021Nov 2026Supported
1313.23Sept 2020Nov 2025End of life
1212.22Oct 2019Nov 2024End of life
1111.22Oct 2018Nov 2023End 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.

Depends on

Must be present for this to run

Deployed with

Commonly run alongside

Alternatives

Solves a similar problem differently