Back to Resources
Blog

Why Isn't Autovacuum Keeping Up? The Vacuum Advisor and the xmin Horizon

Benjamin Smith·October 2, 2026

The Vacuum Advisor page inside Oracle Enterprise Manager 24ai for a PostgreSQL Database target, with Vacuum Advisor selected in the navigation tree. The XID Consumption panel reads a cluster-wide max XID age of 13,084,248, oldest database template1, about 0.6% of the 2B wraparound limit. Beside it, Vacuum Health shows a dead-tuple bloat KPI and an Autovacuum runs · 24h KPI above a red-edged root-cause card naming a backend that is holding xmin, with a SELECT pg_terminate_backend statement as the suggested action. Below, the Detections band counts tables past their autovacuum trigger, the highest dead-tuple ratio, bloat findings, and xmin horizon blocked: Yes, followed by the top of the Tables · Vacuum Recommendations list

A table's size keeps climbing. pg_stat_user_tables shows autovacuum running against it, and the dead-tuple count goes up anyway. In most cases one of three things is going on. The table's own storage parameters have moved the point at which autovacuum fires, so it fires later than the cluster settings suggest. Autovacuum fires, but not often enough for the rate at which rows are updated and deleted. Or a session, a replication slot, or a prepared transaction is holding the transaction horizon back, and no vacuum anywhere in the cluster can remove the dead rows until it lets go.

The Vacuum Advisor works through all three from PostgreSQL's own statistics catalogs. It recomputes each table's trigger point from its effective settings, estimates how much a table has already grown avoidably, names whatever is holding the xmin horizon, and tracks transaction-ID age against the wraparound limit. Every corrective statement it produces is text for you to review and run in your own tooling.

This is the third of four posts on the advisory features in the PostgreSQL Plug-in for Oracle Enterprise Manager. Part one covered the Index Advisor, and part two covered Plan Analysis and the Plan Drift Advisor.



The page, top to bottom

The Vacuum Advisor sits in the target's navigation tree under the database name, and in the target menu. It collects once when the page loads and does not refresh on its own, because these figures move slowly and the catalog queries behind them are heavier than most. Reload the page for fresh numbers.

The page narrows as you read down it, from cluster-wide risk to the individual table:

  • XID Consumption is one line: the cluster's maximum XID age, the oldest database, and that age as a share of the 2-billion wraparound limit. Wraparound is cluster-scoped, so the line reports the cluster's oldest database rather than the one you are viewing, and that is often a template database.
  • Vacuum Health has two KPIs and a card. Dead-tuple bloat is dead tuples as a share of all rows across the tables the advisor lists. Autovacuum runs · 24h counts autovacuum runs across the database over the last day, computed from daily snapshots of PostgreSQL's lifetime autovacuum_count. It reads "—" until it has two snapshots to compare, and it is read through an Enterprise Manager job, so the target needs Preferred Credentials. Below them, the xmin horizon root-cause card turns red and names the holder and its release command when cleanup is pinned, and turns green when nothing is holding the horizon.
  • Detections counts what the tables below contain: tables past their autovacuum trigger, the highest dead-tuple ratio, bloat findings, and whether the xmin horizon is blocked.
  • Four tables follow: Tables · Vacuum Recommendations, the per-table verdict list; xmin Horizon · Holder Detail; Per-Table Vacuum Recommendations (Full Detail), the evidence behind each verdict; and Avoidable-Growth / Bloat Estimate.

Everything except the bloat estimate reads catalogs the monitoring role can already see, such as pg_stat_user_tables, pg_class, pg_settings, pg_stat_activity, pg_replication_slots, and pg_prepared_xacts, and needs no extension.

Recomputing each table's trigger point

For every user table with dead tuples or per-table storage parameters, the advisor recomputes the dead-tuple count at which autovacuum fires:

autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × reltuples

It uses each table's effective settings, so a value in the table's reloptions replaces the cluster-wide setting. The Full Detail table shows the inputs side by side: dead tuples, estimated live rows, the trigger point, the effective scale factor, and the reloptions in effect. A table that has been given a high scale factor, or had autovacuum switched off, shows up here with the setting that explains it.

Each listed table gets a severity:

SeverityCondition
HIGHDead tuples are past the trigger point, and the last autovacuum is missing or more than a day old
MEDIUMDead tuples are past the trigger point
LOWEvery other listed table

A table with no dead tuples and no storage parameters is not listed. A healthy table that carries storage parameters is listed at LOW, so the override stays visible.

The recommendation

A table past its trigger point gets a statement that lowers its scale factor:

ALTER TABLE public.orders SET (autovacuum_vacuum_scale_factor=0.02);

The proposed factor is the lower of two candidates: a factor that would have fired at half the dead tuples the table has now, and half the table's current effective factor, with a floor of 0.01. Above that floor, the result is at most half the current factor, so autovacuum fires sooner on that table. When autovacuum is switched off in the table's reloptions, the same statement also sets autovacuum_enabled=true, and the row's evidence sentence ends with a note that autovacuum is disabled on the table.

Tables that are not past their trigger get a comment instead of a statement: -- autovacuum is keeping up (no tuning needed), or -- per-table storage parameters in effect: followed by the table's reloptions.

The Tables · Vacuum Recommendations list. Each row shows the table name, dead ratio, last autovacuum time, severity, and recommendation. vacuum_tbl and bloat_churn are HIGH with no last autovacuum and recommendations that set autovacuum_enabled=true along with autovacuum_vacuum_scale_factor=0.1. Several pgbench tables and vacuum_demo_bravo are MEDIUM with a recent last autovacuum and a recommendation to set autovacuum_vacuum_scale_factor=0.1. seqscan_big is LOW with the comment per-table storage parameters in effect: autovacuum_enabled=false

To act on a row:

  1. Find the table in Tables · Vacuum Recommendations, which is sorted worst first.
  2. Read its row in Per-Table Vacuum Recommendations (Full Detail): dead tuples against the trigger point, when autovacuum last ran, and any reloptions.
  3. Click the Recommendation cell. The statement opens in a copy dialog.
  4. Review it and run it in your own tooling.

The finding clears at the next collection after dead tuples fall back under the trigger point. There is nothing to close by hand.

Bloat that has already happened

The trigger-point check shows that cleanup is falling behind. The Avoidable-Growth / Bloat Estimate shows how much a table has already grown as a result: its free space and dead tuples as a share of the table.

This section needs the pgstattuple extension. Extensions are per-database in PostgreSQL, so it has to be installed in each database you want estimated. The plug-in detects it through the catalog and does not install it. Without it, this one section is empty and the rest of the page is unaffected. No extra grant is needed: the function the estimate uses requires pg_stat_scan_tables, which pg_monitor already includes.

The estimate uses pgstattuple_approx, which reads only the pages PostgreSQL has not already marked all-visible, so it does not scan whole tables. It covers ordinary permanent tables in user schemas of at least 256 KB. Each database's estimate runs within a 30-second budget, and on a database with very many tables the smallest remaining tables are skipped once that budget is spent. A table the monitoring role cannot read is skipped and logged, and the rest of the database is still estimated.

SeverityCondition
HIGHFree space of 40% or more, or dead tuples of 20% or more
MEDIUMFree space of 20% or more, or dead tuples of 10% or more
LOWEverything else in the set

Each row carries an evidence sentence, for example: "Table is ~46.2% free space (912 MB) with 2204118 dead tuples (~21.7%); a VACUUM (FULL) or pg_repack would reclaim the avoidable growth". The order of work follows from the two sections: address the vacuum recommendation first, so the table stops growing, then reclaim the space in your own tooling with a VACUUM, or a rewrite such as VACUUM (FULL) or pg_repack for severe cases.

What is holding the xmin horizon

Vacuum can only remove a dead row once no open snapshot might still need it. While something holds the cluster's xmin horizon back, autovacuum runs and reclaims nothing, and tuning trigger points makes no difference. On the page, this looks like Tables past autovacuum trigger rising while Last Autovacuum keeps updating.

The advisor identifies what is holding the horizon and shows the statement that would release it. It detects three kinds of holder:

Holder TypeRead fromSuggested Action
backendpg_stat_activity.backend_xminNames the user and application and how long the session has held xmin, then SELECT pg_terminate_backend(<pid>);
replication_slotpg_replication_slotsFor an active slot, names its PID and says to check the standby or subscriber. For an inactive slot, says it is pinning the horizon. Either way, then SELECT pg_drop_replication_slot('<slot>'); if the slot is obsolete
prepared_transactionpg_prepared_xactsNames the owner, then ROLLBACK PREPARED '<gid>'; if the transaction is abandoned

The collector excludes its own session from the list.

The xmin Horizon · Holder Detail table, oldest holder first. The top row has Is Horizon set to 1: a backend, pid 2499257, in the postgres database, active, with an xmin age of 13,082,905 and a wraparound severity of 0. Four more backends follow with Is Horizon 0 and xmin ages between 1 and 9. Each row's Suggested Action begins Long-running session (user postgres, app psql) holding xmin since

The holder list is sorted oldest first. The row with Is Horizon set to 1 is the one holding cleanup back. The others are queued behind it, and on a busy system many rows is normal. A holder with an xmin age of 0 is listed but not counted as blocking by the KPI or the root-cause card; it is typically a short transaction still in flight.

The same detection has a live view at Realtime ▸ Vacuum xmin Horizon, with Auto Refresh set to No Refresh, 15, 30, or 60 seconds. It re-queries the current state on each refresh, and the page has no action buttons.

The Realtime Vacuum xmin Horizon page in Oracle Enterprise Manager 24ai, with Auto Refresh set to 60 seconds. The Vacuum xmin Horizon: Root Cause table lists three backends in the postgres database. The first has Is Horizon set to 1 and an xmin age of 12,746,247. The other two have Is Horizon 0 and xmin ages of 133 and 115

To work a pinned horizon on the live page:

  1. Find the row where Is Horizon is 1.
  2. Copy its Suggested Action, review it, and run it in your own session if it is safe to do so.
  3. On the next refresh, check which holder now has Is Horizon set to 1. Holders are often layered, and releasing the oldest can reveal the next.

An xmin Age that grows between refreshes means the holder is still accumulating.

Wraparound

Transaction-ID age and multixact age are tracked against the wraparound limit at both database and table level, as four separate metrics, because a database can be in good shape on one and close to the limit on the other. The thresholds ship enabled, and the XID Consumption line shows the cluster's live position.

The table-level alerts say that the table is holding back the database's datfrozenxid or datminmxid and to check autovacuum, so a table that autovacuum is not reaching raises its own alert.

Lowering the scale factor does not help with an age alert. It changes when autovacuum fires next time, and does nothing to the age a table has already accumulated; only a freeze advances relfrozenxid. Check the xmin horizon first, because a freeze cannot advance past a pinned horizon either. Once the horizon is clear, freeze the named table in your own tooling:

VACUUM (FREEZE) public.orders;

Age history accumulates in the plug-in's agent-local store from the first collection, with no configuration. The daily condense keeps each day's maximum XID and multixact ages.

Watching a manual vacuum

Realtime ▸ Vacuums in Progress shows what pg_stat_progress_vacuum reports for a running VACUUM, joined to its session: the process ID, database, current phase, heap blocks total, scanned, and vacuumed, percent complete, session state, backend type, and start time. It needs no extension. It is the page to have open while a VACUUM or VACUUM (FREEZE) you started works through a table, with the same Auto Refresh choices as the xmin page.

The Realtime Vacuums in Progress page in Oracle Enterprise Manager 24ai, with Auto Refresh set to 60 seconds. The Vacuum Progress table lists two client backends in the postgres database, both in the scanning heap phase, each with 35,715 heap blocks total, about 19,500 and 21,900 blocks scanned, 0 blocks vacuumed, and 0% vacuumed

An empty table means no vacuum is running.

When a finding becomes an alert

The advisor's findings are standard Enterprise Manager metrics, with editable collection schedules, per-target thresholds, alert history, and notification routing through whatever connector you have bound. The shipped monitoring templates set them fleet-wide.

MetricCollectedDefault WarningDefault CriticalOccurrences
Vacuum Advisor (Frequency)Every 30 minutesSeverity = HIGHNot set1
Table Bloat EstimateEvery 30 minutesSeverity = HIGHNot set1
Vacuum xmin Horizon (Root Cause)Every 30 minutesWraparound Severity ≥ 1Wraparound Severity ≥ 22
Database and table XID and multixact unfrozen age (four metrics)Every 30 minutes1,000,000,0001,500,000,0002

Wraparound Severity is a band on the holder's XID age: 1 at 1 billion, 2 at 1.5 billion. The xmin and age metrics require two consecutive collections over the line, so a batch job that holds the horizon for one collection does not raise an alert, and a holder still there at the next collection does. The Vacuum Advisor alert message includes the table, its dead-tuple count, its trigger point, and the recommended statement; the xmin alert names the holder and its release command.

The Vacuum Advisor does not run any of the statements it shows. The ALTER TABLE tuning, pg_terminate_backend, pg_drop_replication_slot, ROLLBACK PREPARED, and VACUUM (FREEZE) statements all appear as text to copy, review, and run yourself.


Do I need any extensions for the Vacuum Advisor?+

Only for the bloat estimate. The trigger-point check, the xmin horizon detection, the wraparound metrics, and both Realtime pages read PostgreSQL's built-in catalogs. The Avoidable-Growth / Bloat Estimate section needs pgstattuple, installed in each database you want estimated. Without it, that one section is empty and nothing else on the page changes. The Monitoring Readiness page reports whether pgstattuple is detected.

Why is autovacuum running but the table keeps growing?+

Check the root-cause card and the xmin Horizon · Holder Detail table first. If a session, replication slot, or prepared transaction is holding the horizon, autovacuum cannot remove the dead rows no matter how often it runs. If the horizon is clear, compare the table's dead tuples with its trigger point in the Full Detail table: a high per-table scale factor, or autovacuum switched off in reloptions, shows up there.

Why isn't my table listed?+

The advisor lists a table only when it has dead tuples or carries per-table storage parameters. A clean table with default settings is not listed. The bloat estimate has its own bounds: ordinary permanent tables in user schemas of at least 256 KB, in databases where pgstattuple is installed.

Will applying the recommended ALTER TABLE fix a wraparound alert?+

No. The scale-factor change affects when autovacuum fires next; it does not reduce the age a table has already accumulated. For an age alert, release any xmin holder first, then run VACUUM (FREEZE) on the table named in the alert.

Is it safe to run the suggested pg_terminate_backend or pg_drop_replication_slot?+

That depends on what the holder is, which is why the advisor shows the statement rather than running it. Terminating a backend ends that session's open transaction. Dropping a replication slot removes the position a standby or subscriber is reading from, so check whether the slot is still in use first. The Suggested Action for an active slot names its PID and says to check the standby or subscriber before dropping it.

Why does Autovacuum runs · 24h show a dash?+

The KPI is the difference between two daily snapshots of PostgreSQL's lifetime autovacuum counts, so it needs two snapshots before it can show a number. It is also read through an Enterprise Manager job, so Preferred Credentials must be set for the target.


Where this fits

The Vacuum Advisor is one of five advisors that arrived in the August 2026 release, 24.1.1.0.0 for Enterprise Manager 24ai and 13.5.15.0.0 for Enterprise Manager 13.5. The current release, 24.1.2.0.0 and 13.5.16.0.0, keeps it and adds fixes.

This is the third of four posts on those advisory features. Part one covered the Index Advisor and the HypoPG and pg_qualstats extensions behind it, and part two covered Plan Analysis and the Plan Drift Advisor. The next post covers Workload History, on replaying weeks of statement statistics from a history store that lives on the agent rather than in your Enterprise Manager repository.

See it in action

If you missed our September 23 webinar, What's New with the PostgreSQL Plug-in for Oracle Enterprise Manager, the recording walks through all five advisors running live in Enterprise Manager 24ai. The Vacuum Advisor demo starts at 16:29.

To see the plug-in in person, visit the Integration Plumbers team at Booth #7024 at Oracle AI World, October 25–28.

Related Resources

Is This Query Still Running the Baseline Plan? Plan Analysis and the Plan Drift Advisor
Blog

Is This Query Still Running the Baseline Plan? Plan Analysis and the Plan Drift Advisor

When a query that was fine last week turns slow, the evidence you need is the plan it actually ran, and that plan is usually gone by the time anyone looks. Plan Analysis keeps it, captured by auto_explain during the query's own execution with nothing re-run, and checks it against five detection rules. The Plan Drift Advisor keeps each query's accepted baseline plans and tells you when a query leaves them. Part two of four on the advisory features in the PostgreSQL Plug-in for Oracle Enterprise Manager.

Sep 21, 2026

Which Index Should You Actually Create? HypoPG, pg_qualstats, and the PostgreSQL Index Advisor
Blog

Which Index Should You Actually Create? HypoPG, pg_qualstats, and the PostgreSQL Index Advisor

Most index advice is a hunch with a table name attached: this table gets scanned a lot, so maybe index something on it. The Index Advisor in the August 2026 release of the PostgreSQL Plug-in for Oracle Enterprise Manager replaces the hunch with two kinds of evidence. HypoPG prices a candidate index by planning against it without building it, and pg_qualstats reports the predicates your workload actually ran that no index served. Part one of four on the release's advisory features: how each layer works, where each one stops, why they deliberately disagree, and what it takes to install them.

Sep 9, 2026

Why a Fortune 100 Bank Chose Integration Plumbers for PostgreSQL Monitoring at Scale
Blog

Why a Fortune 100 Bank Chose Integration Plumbers for PostgreSQL Monitoring at Scale

When a global bank needed to monitor 2,000+ PostgreSQL instances across 40 countries, every solution they evaluated fell short — except one. Here's why scale made the difference.

Feb 24, 2026

Not Sure Where to Start?

Take our free OTEL Maturity Assessment to identify gaps and get a personalized action plan.

Take the Free Assessment