Filter concepts by levelShowing all levels.

PostgreSQL · Section 18

VACUUM, ANALYZE and Autovacuum

Level
intermediate
Read
34 min
Concepts
7

VACUUM, VACUUM FULL and ANALYZE as three genuinely distinct operations, autovacuum as the essential background process that turns vacuuming from a manual scheduling problem into a self-regulating one, why routine vacuuming (not VACUUM FULL) is the intended steady state, table and index bloat as the moving balance between dead-tuple creation and reclamation, the database-wide reach of a single long-running transaction blocking cleanup, stale planner statistics as bloat's quieter, less-visible sibling problem, and the threshold/scale-factor formula that governs when autovacuum actually acts.

What is true here

  1. VACUUM reclaims dead tuple space for reuse; ANALYZE refreshes planner statistics — two independent concerns, commonly run together.
  2. Autovacuum decides per-table from each table's own activity — essential infrastructure, never disable it globally.
  3. The goal is routine VACUUM keeping pace so VACUUM FULL, with its blocking ACCESS EXCLUSIVE lock, is never actually needed.
  4. Bloat is a moving balance between dead-tuple creation (writes) and reclamation (VACUUM) — n_dead_tup/n_live_tup is the practical signal.
  5. One idle-in-transaction session blocks VACUUM database-wide, on tables it never touched — a conservative, documented consequence of MVCC.

What you will be able to do

  • Explain the genuine difference between VACUUM, VACUUM FULL and ANALYZE, and when each is the right tool
  • Diagnose table/index bloat via pg_stat_user_tables and rule it out before assuming an indexing problem
  • Find and resolve a long-running transaction blocking cleanup database-wide via pg_stat_activity
  • Explain autovacuum's threshold formula and tune it per table for an exceptional workload
From write activity to a healthy, well-informed table
crosses thistable's thresholdunless a long transactionis holding it back

Write activity

creates dead tuples + shifts data distribution

Autovacuum reacts

per-table thresholds, VACUUM + ANALYZE

Bounded bloat, fresh stats

unless something blocks it

  • Write activity — creates dead tuples + shifts data distribution
    • leads to Autovacuum reacts (crosses this table's threshold)
  • Autovacuum reacts — per-table thresholds, VACUUM + ANALYZE
    • leads to Bounded bloat, fresh stats (unless a long transaction is holding it back)
  • Bounded bloat, fresh stats — unless something blocks it

The three operations

VACUUM, VACUUM FULL and ANALYZE, and the background process that runs them automatically.

VACUUM, VACUUM FULL and ANALYZE

coreintermediate

VACUUM reclaims dead tuple space for reuse. VACUUM FULL rewrites the whole table to reclaim space AND shrink the file, at the cost of an exclusive lock. ANALYZE is a separate job entirely: it collects statistics about a table's data distribution so the query planner can make good decisions — it does not touch dead tuples at all. VACUUM ANALYZE runs both together in one pass.

Think of it as

VACUUM and ANALYZE solve two unrelated problems that happen to run on the same schedule in practice: VACUUM is about reclaiming space and preventing bloat, while ANALYZE is about keeping the planner's picture of the data's shape (how many rows, how values are distributed) accurate enough to choose good query plans. A table can desperately need one without needing the other — a table with heavy UPDATE churn but a stable data distribution needs VACUUM more than ANALYZE, while a table that only grows via INSERT (no dead tuples) but whose row count has changed dramatically needs ANALYZE more than VACUUM.

sql
VACUUM accounts;               -- reclaim dead tuple space for reuse
ANALYZE accounts;              -- refresh planner statistics
VACUUM ANALYZE accounts;       -- both, in one pass
VACUUM FULL accounts;          -- reclaim AND shrink -- rare, blocking

What we're doing: Show a table where a stale row-count estimate (fixed by ANALYZE) causes a bad query plan, entirely independent of whether the table has any bloat.

stale_stats_vs_bloat.sqlsql
-- a table that only ever grows via INSERT -- zero dead tuples, zero bloat
INSERT INTO events SELECT generate_series(1, 1000000);
-- planner still thinks the table has its OLD row count, from creation time
EXPLAIN SELECT * FROM events WHERE id < 500000;
-- may choose a sequential scan, expecting far fewer matching rows than reality

ANALYZE events;  -- refreshes the row count and value distribution
EXPLAIN SELECT * FROM events WHERE id < 500000;
-- now chooses correctly, informed by the accurate statistics
1–2
This table has no dead tuples at all — VACUUM would find nothing to do here.
4
The planner's stale statistics, not any bloat, are the actual problem.
7–8
ANALYZE alone — no VACUUM involved — is what fixes the plan.
Output
-- before ANALYZE: plan based on stale row-count estimate
-- after ANALYZE: plan based on accurate current statistics

Why this works: This table demonstrates the two concerns are genuinely independent — an INSERT-only table accumulates zero dead tuples (nothing for VACUUM to reclaim) but can still have wildly stale planner statistics if ANALYZE has not run recently, since row count and value distribution are ANALYZE's concern, not VACUUM's.

Running VACUUM and assuming it also refreshes planner statistics

Wrong

sql
-- after a large bulk load:
VACUUM events;  -- reclaims dead tuple space (there may be none, for a fresh load)
-- assumption: "the planner's statistics are now up to date too"
-- reality: VACUUM alone does NOT run ANALYZE

Better

sql
-- after a large bulk load:
VACUUM ANALYZE events;  -- explicitly runs BOTH
-- or, separately:
ANALYZE events;  -- if statistics are the actual concern, not bloat

What you see: Query plans remain poor immediately after a maintenance window that ran plain VACUUM, because the planner is still working from statistics gathered before a large data change — VACUUM alone never touched them.

Why: VACUUM and ANALYZE are separate operations that happen to be commonly run together — plain VACUUM with no ANALYZE keyword reclaims space only, leaving the planner's row-count and distribution statistics exactly as stale as they were before, which is why VACUUM ANALYZE (or autovacuum's combined behavior) is the usual recommendation rather than VACUUM alone.

Two unrelated jobs that happen to run together

VACUUM / VACUUM FULL

  • +Reclaims dead tuple space
  • +VACUUM: reuse only, no lock; FULL: reuse + shrink, ACCESS EXCLUSIVE lock
  • +Never touches planner statistics

ANALYZE

  • Refreshes row-count and value-distribution statistics
  • The query planner reads these to choose a plan
  • Never touches dead tuples
  • VACUUM / VACUUM FULL
    • Reclaims dead tuple space
    • VACUUM: reuse only, no lock; FULL: reuse + shrink, ACCESS EXCLUSIVE lock
    • Never touches planner statistics
  • ANALYZE
    • Refreshes row-count and value-distribution statistics
    • The query planner reads these to choose a plan
    • Never touches dead tuples

VACUUM vs VACUUM FULL vs ANALYZE

VACUUM vs VACUUM FULL vs ANALYZE
CommandWhat it addressesLock required
VACUUMdead tuple space (reuse, not shrink)none blocking ordinary access
VACUUM FULLdead tuple space (reuse AND shrink)ACCESS EXCLUSIVE
ANALYZEplanner statistics — unrelated to dead tuplesnone blocking ordinary access

Remember: VACUUM reclaims dead tuple space for reuse (not shrink). VACUUM FULL reclaims AND shrinks, at the cost of an ACCESS EXCLUSIVE lock. ANALYZE refreshes planner statistics — a completely separate concern from dead tuples. A table can need one badly without needing the other at all.

See also: how vacuum removes obsolete row versions · statistics collection and stale statistics

Autovacuum Workers and Why Autovacuum Is Essential

coreintermediate

Autovacuum is a background process that automatically runs VACUUM (and ANALYZE) on tables once they accumulate enough dead tuples or data changes, using one or more worker processes so a busy database does not need a human to remember to run VACUUM manually. It is essential because without it, dead tuples and stale statistics would accumulate indefinitely on any actively-written table, degrading performance and eventually risking transaction ID wraparound.

Think of it as

Autovacuum exists because "someone remembers to run VACUUM regularly" does not scale as an operational strategy — tables have wildly different write patterns, and a fixed manual schedule is either too frequent for quiet tables (wasted work) or too infrequent for hot tables (bloat and stale statistics accumulate). Autovacuum instead reacts to actual activity per table, launching a worker when a table's own accumulated changes cross its own threshold — turning vacuuming from a scheduling problem into a self-regulating one.

sql
SHOW autovacuum;              -- on (the default)
SHOW autovacuum_max_workers;  -- 3 (the default)
SHOW autovacuum_naptime;      -- 1min (the default check interval)

-- see autovacuum activity per table
SELECT relname, last_autovacuum, last_autoanalyze
  FROM pg_stat_user_tables;

What we're doing: Observe autovacuum actually kick in on a table after enough changes accumulate, via last_autovacuum in pg_stat_user_tables.

observe_autovacuum_trigger.sqlsql
SELECT relname, n_dead_tup, last_autovacuum
  FROM pg_stat_user_tables WHERE relname = 'accounts';
-- last_autovacuum: (null) -- never run yet

-- generate enough dead tuples to cross the threshold
UPDATE accounts SET balance = balance + 1;  -- touches every row, repeat a few times

-- wait roughly one autovacuum_naptime interval...
SELECT relname, n_dead_tup, last_autovacuum
  FROM pg_stat_user_tables WHERE relname = 'accounts';
-- last_autovacuum: a real timestamp -- autovacuum found and processed the table on its own
3
Before enough changes accumulate, autovacuum has never touched this table — there was nothing to justify it yet.
5–6
Enough UPDATEs push n_dead_tup past this table's autovacuum threshold.
9
No human ran VACUUM manually — autovacuum found the table crossed its threshold and handled it automatically.
Output
 relname  | n_dead_tup | last_autovacuum 
----------+------------+-----------------
 accounts |          0 | 

(after updates and a wait)

 relname  | n_dead_tup | last_autovacuum 
----------+------------+-----------------
 accounts |          0 | 2026-08-24 10:15:03

Why this works: n_dead_tup dropping back to a low number alongside a fresh last_autovacuum timestamp is direct, observable proof the background process did its job without any manual intervention — this is exactly the self-regulating behavior that makes "someone remembers to run VACUUM" an unnecessary operational burden for the common case.

Disabling autovacuum globally to "avoid unpredictable pauses" during peak traffic

Wrong

sql
ALTER SYSTEM SET autovacuum = off;
SELECT pg_reload_conf();
-- reasoning: "autovacuum sometimes runs at inconvenient times, disable it
-- and run VACUUM manually during a maintenance window instead"

Better

sql
-- tune autovacuum's aggressiveness instead of disabling it --
-- e.g. lower the cost delay so it works faster, or adjust thresholds
-- per table for genuinely hot tables:
ALTER TABLE accounts SET (autovacuum_vacuum_scale_factor = 0.02);
-- autovacuum stays ON, just tuned to this table's actual write pattern

What you see: Weeks after disabling autovacuum, the database experiences severe query slowdowns from accumulated bloat, or in extreme cases approaches transaction ID wraparound protection and refuses new writes — a much worse outage than any autovacuum pause would have caused.

Why: Autovacuum's occasional resource usage is a controllable, tunable cost (via cost delay/limit settings, or per-table thresholds) — disabling it entirely removes the database's only automatic defense against unbounded bloat and wraparound, trading a manageable, tunable inconvenience for an unbounded, eventually catastrophic risk.

A self-regulating loop, not a human-remembered schedule

launcher checks

every autovacuum_naptime (default 1 min)

per-table threshold

compares each table's own dead-tuple/change count

worker runs VACUUM/ANALYZE

only on tables that crossed their threshold

  1. launcher checks — every autovacuum_naptime (default 1 min)
  2. per-table threshold — compares each table's own dead-tuple/change count
  3. worker runs VACUUM/ANALYZE — only on tables that crossed their threshold

Manual VACUUM vs autovacuum

Manual VACUUM vs autovacuum
PropertyManual VACUUM onlyAutovacuum
Triggera human remembers to run itper-table thresholds, checked automatically
Adapts to per-table write patternsno — same schedule for every tableyes — busier tables vacuum more often
Risk if forgottenunbounded bloat, stale stats, wraparound risklow — runs automatically as needed

Remember: Autovacuum is a background process (one or more workers, launched periodically) that decides per-table, from each table's own accumulated dead-tuple/change count, when to run VACUUM/ANALYZE automatically — turning vacuuming from a manual scheduling problem into a self-regulating one. Never disable it globally; tune its aggressiveness per-table instead.

See also: vacuum vacuum full and analyze · autovacuum thresholds and scale factors

Routine Vacuum vs Table-Rewriting VACUUM FULL

standardintermediate

The documented operational goal is running routine VACUUM (via autovacuum, ordinarily) often enough that a table never accumulates enough bloat to need VACUUM FULL at all — routine vacuuming is meant to be the steady-state norm, and VACUUM FULL is meant to be a rare, deliberate recovery operation for a table that has already bloated far beyond what routine vacuuming alone can fix.

Think of it as

Think of routine VACUUM as ongoing maintenance that keeps a table's "usable free space" healthy, and VACUUM FULL as major surgery for a table that maintenance was neglected on for too long. If autovacuum is tuned correctly and running consistently, a table's dead-tuple count should stay roughly bounded relative to its live-tuple count — it never needs the disruptive full rewrite. Needing VACUUM FULL regularly on a table is itself a signal that routine vacuuming has fallen behind, not a normal maintenance step to schedule proactively.

sql
-- the routine, steady-state signal to check:
SELECT relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / GREATEST(n_live_tup, 1), 1) AS dead_pct
  FROM pg_stat_user_tables
 ORDER BY dead_pct DESC
 LIMIT 10;

What we're doing: Investigate a table with a persistently high dead-tuple ratio, discover the root cause was a long-running transaction blocking autovacuum, fix the root cause, and confirm routine vacuuming resumes keeping pace on its own.

diagnose_and_recover.sqlsql
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
  FROM pg_stat_user_tables WHERE relname = 'accounts';
-- n_dead_tup is very high, last_autovacuum is old -- something is blocking it

SELECT pid, state, now() - xact_start AS duration
  FROM pg_stat_activity
 WHERE state = 'idle in transaction'
 ORDER BY duration DESC LIMIT 5;
-- finds a session idle in transaction for 6 hours

SELECT pg_terminate_backend(41213);  -- the offending session

-- routine autovacuum now resumes catching up on its own -- no VACUUM FULL needed,
-- since the bloat had not yet reached the point where a full rewrite is the only option
1–3
This is the diagnostic starting point — a high dead-tuple ratio is the symptom, not yet the diagnosis.
8
The actual root cause: a long-idle transaction has been holding back the vacuum horizon for hours.
12
Fixing the root cause lets routine autovacuum resume doing its job — often avoiding the need for VACUUM FULL entirely.
Output
pg_terminate_backend
-----------------------
 t

(autovacuum resumes progress on its own shortly after)

Why this works: Reaching straight for VACUUM FULL without diagnosing why routine vacuuming fell behind treats the symptom instead of the cause — in this case the actual fix was terminating a stuck session, after which ordinary autovacuum was fully capable of catching up without ever needing the disruptive full rewrite.

Scheduling VACUUM FULL as routine, recurring maintenance

Wrong

sql
-- crontab, weekly:
-- 0 3 * * 0  psql -c "VACUUM FULL accounts;"
-- treats VACUUM FULL as a normal maintenance task rather than a
-- recovery operation for an abnormal situation

Better

sql
-- monitor the dead-tuple ratio and last_autovacuum timestamp instead;
-- tune autovacuum settings if routine vacuuming is genuinely not
-- keeping pace, and reserve VACUUM FULL for confirmed, diagnosed bloat:
SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables
 WHERE n_dead_tup > 100000 ORDER BY n_dead_tup DESC;

What you see: The weekly VACUUM FULL causes a recurring maintenance-window outage (from its ACCESS EXCLUSIVE lock) that the team has come to accept as normal, while the underlying reason routine vacuuming is not keeping pace on its own is never actually investigated or fixed.

Why: A table that genuinely needs a scheduled VACUUM FULL every week is a table where routine vacuuming is failing for some fixable reason (too-conservative autovacuum settings, a recurring long-running transaction, insufficient autovacuum_max_workers) — treating the symptom with recurring VACUUM FULL avoids ever diagnosing and fixing that underlying reason.

When each is the appropriate response

When each is the appropriate response
SituationAppropriate response
Ordinary, ongoing write activityroutine VACUUM (autovacuum) — the steady-state norm
A table that bloated after autovacuum was disabled/blocked for a long timeVACUUM FULL — deliberate, one-time recovery
VACUUM FULL "needed" every week on the same tableinvestigate why routine vacuuming is falling behind — do not just keep running VACUUM FULL

Remember: The goal is routine VACUUM (via autovacuum) running often enough that VACUUM FULL is never actually needed. VACUUM FULL is a rare, deliberate recovery operation, not routine maintenance — needing it repeatedly on the same table is a signal that routine vacuuming has fallen behind for a fixable reason, worth diagnosing rather than working around.

See also: vacuum vacuum full and analyze · how long running transactions can prevent cleanup

Advertisement

What goes wrong

Bloat as a moving balance, the database-wide reach of one blocked transaction, and stale statistics as the quieter sibling problem.

Table and Index Bloat

coreintermediate

Bloat is disk space consumed by dead tuples (or, for an index, dead index entries) that VACUUM has not yet reclaimed — it makes tables and their indexes physically larger than the live data alone would require, slowing down scans that now have to skip over more dead space to find live rows, and wasting cache memory on pages that hold no useful data.

Think of it as

Bloat is the accumulated backlog between "how much dead space has been created" and "how much VACUUM has actually reclaimed" — it is not a fixed property of a table, but a moving balance that grows with write activity and shrinks with successful vacuuming. A table under a heavy UPDATE/DELETE workload with vacuuming keeping pace stays roughly steady-state; the same table with vacuuming falling behind (blocked by a long transaction, undersized autovacuum settings, or disabled entirely) accumulates bloat without bound.

sql
-- the practical bloat signal: dead tuples relative to live ones
SELECT relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / GREATEST(n_live_tup + n_dead_tup, 1), 1) AS dead_pct
  FROM pg_stat_user_tables
 ORDER BY n_dead_tup DESC
 LIMIT 10;

What we're doing: Simulate a table accumulating bloat under sustained UPDATE churn without vacuuming keeping pace, and observe the growing dead-tuple ratio directly.

observe_bloat_growth.sqlsql
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'accounts';
-- n_live_tup = 10000, n_dead_tup = 0

-- simulate a long-running transaction blocking vacuum, then heavy UPDATE churn
BEGIN;  -- Session A: opens and stays open, holding back the vacuum horizon
SELECT 1;

-- Session B, meanwhile:
UPDATE accounts SET balance = balance + 1;  -- touches all 10000 rows, repeatedly

SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'accounts';
-- n_dead_tup climbing steadily -- autovacuum cannot reclaim any of it
-- while Session A's transaction stays open
4–5
Session A's open, idle transaction is the root cause -- it holds back the point at which any of these dead tuples can be considered reclaimable.
8–10
Every UPDATE creates more dead tuples, and none of them can be reclaimed until Session A finally commits or rolls back.
Output
 n_live_tup | n_dead_tup 
------------+------------
      10000 |          0

(after updates, Session A still open)

 n_live_tup | n_dead_tup 
------------+------------
      10000 |      30000

Why this works: This makes bloat's dependence on both write activity AND successful vacuuming directly visible — the dead tuples are being created by ordinary UPDATE traffic exactly as expected, but Session A's open transaction is specifically what prevents VACUUM from doing anything about them, which is exactly the compounding of costs the earlier Transaction Duration concept described.

Diagnosing slow queries without checking whether table bloat is the actual cause

Wrong

sql
-- queries against "accounts" have gotten steadily slower --
-- first instinct: add more indexes, or increase work_mem
CREATE INDEX idx_accounts_extra ON accounts (some_column);
-- may not help at all if the real problem is a bloated table
-- forcing every scan (index or sequential) to wade through dead space

Better

sql
-- check the dead-tuple ratio FIRST
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'accounts';
-- if bloat is high, address that directly (fix what's blocking vacuum,
-- run VACUUM, or VACUUM FULL if it has already accumulated significantly)
-- before assuming the schema or indexing strategy is the problem

What you see: New indexes or tuning changes made in response to a slow query provide little or no improvement, because the actual bottleneck was a heavily bloated table forcing every scan strategy — indexed or not — to read far more physical pages than the live data alone would require.

Why: An index scan on a bloated table still has to traverse a bloated index and then fetch bloated heap pages for any rows the index does not cover — bloat degrades every access pattern, not just sequential scans, which is why it needs to be ruled out (via the dead-tuple ratio) before assuming a purely schema- or index-level fix will help.

Bloat is a moving balance, not a fixed property
normallyif vacuumingfalls behind

Write activity

UPDATE / DELETE create dead tuples

Dead tuples accumulate

inflate physical size beyond live data

VACUUM reclaims

shrinks the backlog, if it keeps pace

Long transaction blocks it

reclaiming stalls — bloat grows unbounded

  • Write activity — UPDATE / DELETE create dead tuples
    • leads to Dead tuples accumulate
  • Dead tuples accumulate — inflate physical size beyond live data
    • leads to VACUUM reclaims (normally)
    • on error, leads to Long transaction blocks it (if vacuuming falls behind)
  • VACUUM reclaims — shrinks the backlog, if it keeps pace
  • Long transaction blocks it — reclaiming stalls — bloat grows unbounded

What bloat costs

What bloat costs
CostWhy it happens
Slower sequential/index scansmore physical pages to read for the same live data
Wasted cache (shared_buffers) spacecache holds dead-tuple pages instead of useful data
Larger backups and higher storage costthe table file itself is physically larger
Index bloat specificallyslower index scans, larger index files, same root cause

Remember: Bloat is the moving balance of dead tuples (table) or dead entries (index) not yet reclaimed by VACUUM — it degrades every access pattern by inflating physical size and wasting cache. n_dead_tup relative to n_live_tup in pg_stat_user_tables is the practical signal; check it before assuming a slow query needs a schema or index fix.

See also: how long running transactions can prevent cleanup · dead tuples

How Long-Running Transactions Can Prevent Cleanup

coreintermediate

VACUUM can only remove a dead tuple once it is certain no open transaction's snapshot could still legitimately need to see it — so a single long-running or idle-in-transaction session, anywhere in the database, holds back the point past which VACUUM can safely reclaim ANY dead tuple, on ANY table, not just tables that session has touched.

Think of it as

VACUUM must be conservative on behalf of a transaction it knows nothing about the future behavior of — a transaction that has been open for an hour might, in its very next statement, query a table it has never touched yet, and its snapshot would need the pre-existing row versions on that table to still be there. Because VACUUM cannot know in advance which tables a long-open transaction might query, it treats the entire database as potentially needed by that transaction's snapshot, which is why the blocking effect is database-wide rather than scoped to whatever tables the long transaction has actually used.

sql
-- find the session(s) holding back the vacuum horizon
SELECT pid, state, age(backend_xmin) AS xmin_age, now() - xact_start AS duration
  FROM pg_stat_activity
 WHERE backend_xmin IS NOT NULL
 ORDER BY xmin_age DESC
 LIMIT 10;

What we're doing: Directly demonstrate one idle-in-transaction session, which never touches "orders", still preventing VACUUM from reclaiming dead tuples on "orders".

idle_transaction_blocks_unrelated_table.sqlsql
-- Session A: opens a transaction, queries an UNRELATED table, then goes idle
BEGIN;
SELECT 1 FROM some_other_table LIMIT 1;
-- (application bug: never commits or rolls back)

-- Session B, meanwhile, generates dead tuples on "orders":
DELETE FROM orders WHERE status = 'cancelled';  -- 5000 rows

VACUUM orders;
SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'orders';
-- n_dead_tup is still high -- VACUUM could not reclaim them

-- diagnosis:
SELECT pid, age(backend_xmin), now() - xact_start FROM pg_stat_activity
 WHERE state = 'idle in transaction';
-- finds Session A, open for a long time, holding back the horizon
2–4
Session A never touches 'orders' at all -- its own query was against a completely different table.
8–10
VACUUM on 'orders' still cannot reclaim the dead tuples, because it has no way to know Session A won't query 'orders' next.
13–15
The diagnostic query is what actually finds the real cause — nothing about the "orders" table itself would reveal it.
Output
DELETE 5000
VACUUM
 n_dead_tup 
------------
       5000

  pid  |    age    |    now - xact_start 
-------+-----------+-------------------------
 41200 |      1203 | 00:47:12

Why this works: This is precisely why long-running transactions are a database-wide operational risk rather than a concern local to whatever tables they happen to use — the fix for 'orders' specifically was never anything to do with the orders table itself, only with finding and resolving Session A's unrelated, forgotten transaction.

Investigating bloat on one table without checking for an unrelated long-running transaction elsewhere

Wrong

sql
-- "orders" is bloated -- investigate orders-specific causes:
--   - check orders' own autovacuum settings
--   - check for locks specifically ON orders
--   - re-run VACUUM orders a few more times, hoping it eventually works
-- none of this addresses the actual root cause elsewhere in the database

Better

sql
-- check database-wide for the ACTUAL blocker first:
SELECT pid, state, age(backend_xmin), now() - xact_start
  FROM pg_stat_activity
 WHERE backend_xmin IS NOT NULL
 ORDER BY age(backend_xmin) DESC LIMIT 5;
-- the answer is very often a session that has nothing to do with
-- the bloated table at all

What you see: Repeated VACUUM attempts on the visibly bloated table make no progress, and time is spent tuning that table's own autovacuum settings or investigating locks specifically on it, while the actual cause — an unrelated idle transaction elsewhere — goes unnoticed.

Why: Because the vacuum-horizon-holding-back effect is database-wide by design, the actual root cause of bloat on any one table is very often NOT anything about that table at all — checking pg_stat_activity for the oldest backend_xmin across the WHOLE database, not just activity on the bloated table, is the correct first diagnostic step.

Why the block is database-wide, not table-scoped
conservativeassumptionappliesdatabase-wide

One idle-in-transaction session

never touched table X

VACUUM cannot know its future queries

might query X next

VACUUM on table X is held back

even though the session never used it

  • One idle-in-transaction session — never touched table X
    • leads to VACUUM cannot know its future queries (conservative assumption)
  • VACUUM cannot know its future queries — might query X next
    • leads to VACUUM on table X is held back (applies database-wide)
  • VACUUM on table X is held back — even though the session never used it

Remember: VACUUM cannot reclaim a dead tuple if ANY open transaction's snapshot could still need it — this is a conservative, database-wide check, so one idle-in-transaction session can block cleanup on tables it never even touched. Diagnose via pg_stat_activity's backend_xmin age across the WHOLE database, not just activity on the bloated table itself.

See also: transaction duration and its costs · table and index bloat

Statistics Collection and Stale Statistics

coreintermediate

ANALYZE samples a table's rows and records what it finds — an estimated row count, the most common values per column, and how values are distributed — into pg_statistic, which the query planner reads when deciding how to execute a query. When those statistics no longer reflect reality (after a large bulk load, a big DELETE, or a data distribution that has genuinely shifted), the planner's cost estimates go wrong and it can choose a bad plan even though nothing about the schema or indexes changed.

Think of it as

The planner never looks at the actual data to decide a plan — it looks at what ANALYZE recorded about the data, which is a snapshot in time, not a live view. This is a deliberate design trade-off: computing exact statistics for every query would be far too slow, so the planner works from a periodically refreshed estimate instead. The catch is that estimate can silently drift out of sync with reality, and unlike bloat (which has an observable dead-tuple counter), stale statistics have no comparably obvious counter — the only real signal is a query plan that looks wrong for the data's actual shape.

sql
SELECT attname, n_distinct, most_common_vals, last_analyze
  FROM pg_stats JOIN pg_stat_user_tables USING (relname)
 WHERE tablename = 'orders' AND attname = 'status';

ANALYZE orders;  -- refresh the statistics explicitly

What we're doing: Show a query plan degrading after a large bulk load leaves statistics stale, then recovering once ANALYZE catches up.

stale_stats_plan_degrades.sqlsql
-- table starts with 1,000 rows, evenly distributed
EXPLAIN SELECT * FROM orders WHERE status = 'pending';  -- reasonable plan, based on accurate stats

-- bulk load adds 990,000 more rows, ALL with status = 'shipped'
INSERT INTO orders (status) SELECT 'shipped' FROM generate_series(1, 990000);

EXPLAIN SELECT * FROM orders WHERE status = 'pending';
-- planner still assumes the OLD, now-wildly-wrong distribution --
-- may pick a poor plan, unaware 'pending' is now a tiny fraction of a huge table

ANALYZE orders;
EXPLAIN SELECT * FROM orders WHERE status = 'pending';
-- planner now has fresh statistics reflecting the true, current distribution
2
The original plan was reasonable — statistics matched reality at that point.
5–6
A massive, distribution-skewing bulk load, but statistics are not automatically refreshed by the INSERT itself.
10–11
Only after ANALYZE explicitly runs does the planner's picture of the data catch up to reality.
Output
-- before ANALYZE: plan based on the pre-bulk-load distribution
-- after ANALYZE: plan reflects the true post-bulk-load distribution

Why this works: The bulk INSERT itself does nothing to update pg_statistic — statistics only change when ANALYZE (explicit or autovacuum-triggered) actually runs, so there is a real window, potentially a long one on a quiet table, where the planner is working from data that no longer describes the table at all.

Assuming a slow query after a large data change is an indexing problem before checking statistics freshness

Wrong

sql
-- after a large bulk load, a previously-fast query is suddenly slow --
-- first instinct: the existing index must not be good enough
DROP INDEX idx_orders_status;
CREATE INDEX idx_orders_status_v2 ON orders (status) INCLUDE (created_at);
-- may not help at all if the planner's statistics, not the index, are the problem

Better

sql
-- check statistics freshness FIRST
SELECT last_analyze, last_autoanalyze FROM pg_stat_user_tables WHERE relname = 'orders';
-- if stale relative to the recent bulk load:
ANALYZE orders;
-- re-check the query plan before assuming the index itself needs to change

What you see: A new or redesigned index provides no improvement for a query that regressed after a large data change, because the planner was never actually choosing the wrong index — it was estimating selectivity from statistics that no longer matched reality.

Why: A stale statistics problem and a genuine indexing problem can produce an identical symptom (a slow query, a suspicious-looking plan) but need entirely different fixes — checking last_analyze/last_autoanalyze against when the underlying data last changed significantly is a fast, cheap way to rule out the statistics explanation before spending effort redesigning indexes.

Statistics drift out of sync with a bulk load
  1. T0

    1,000 rows, evenly distributed

    planner statistics match reality

  2. T1

    bulk load: +990,000 rows, all "shipped"

    pg_statistic is NOT updated by the INSERT itself

  3. T2

    query plan degrades

    planner still assumes the old, now-wrong distribution

  4. T3

    ANALYZE runs

    statistics refreshed — plan recovers

  1. T0: 1,000 rows, evenly distributed — planner statistics match reality
  2. T1: bulk load: +990,000 rows, all "shipped" — pg_statistic is NOT updated by the INSERT itself
  3. T2: query plan degrades — planner still assumes the old, now-wrong distribution
  4. T3: ANALYZE runs — statistics refreshed — plan recovers

What ANALYZE records vs what it is used for

What ANALYZE records vs what it is used for
Recorded (pg_stats)Used by the planner to estimate
Estimated row countoverall table size for cost calculations
Most common values + their frequencieshow selective an equality filter (WHERE x = value) really is
Histogram of value rangeshow selective a range filter (WHERE x > value) really is
Correlation (physical vs logical ordering)whether an index scan or sequential scan is cheaper

Remember: ANALYZE records what the planner believes about the data (row counts, most-common values, histograms) into pg_statistic — a snapshot, not a live view. A large bulk load, big DELETE, or distribution shift can leave that snapshot badly stale until the next ANALYZE, producing a query plan that looks wrong for no reason visible in the schema or indexes. Check last_analyze before assuming a slow query needs an indexing fix.

See also: vacuum vacuum full and analyze · autovacuum thresholds and scale factors

Advertisement

Tuning it

The threshold/scale-factor formula that governs exactly when autovacuum acts.

Autovacuum Thresholds and Scale Factors

standardintermediate

Autovacuum decides when to vacuum a table using a formula: threshold = base threshold + scale factor × table size (in rows). The base threshold (default 50) is a small fixed floor so tiny tables still get vacuumed promptly; the scale factor (default 0.1, meaning 10%) makes the real trigger point scale with table size, so a huge table does not get vacuumed constantly over proportionally tiny changes.

Think of it as

The formula exists to solve a problem a single fixed number can't: a 100-row table and a 100-million-row table should not wait for the same absolute number of dead tuples before vacuuming — a fixed threshold of, say, 10,000 dead tuples would vacuum the small table constantly (relative to its size, that's enormous churn) while letting the huge table accumulate a comparatively tiny, insignificant fraction of bloat before triggering. Combining a small base threshold with a size-proportional scale factor gives small tables responsive vacuuming and large tables a sensible, non-disruptive cadence, both from the same formula.

sql
SHOW autovacuum_vacuum_threshold;      -- 50
SHOW autovacuum_vacuum_scale_factor;   -- 0.1

-- tune a specific hot, large table more aggressively
ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_threshold = 1000
);

What we're doing: Compute the actual vacuum trigger point for a 50-million-row table under the default settings, and show why that number is often too permissive for a genuinely hot table.

compute_trigger_point.sqlsql
SELECT reltuples::bigint AS estimated_rows FROM pg_class WHERE relname = 'orders';
-- estimated_rows = 50,000,000

-- default formula: 50 + 0.1 * 50,000,000 = 5,000,050 dead tuples needed
-- for a table receiving heavy UPDATE traffic, that is a LOT of accumulated
-- bloat before autovacuum even considers touching it

ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01);
-- new threshold: 50 + 0.01 * 50,000,000 = 500,050 -- triggers 10x sooner
2
A genuinely large table — this is where the default scale factor's trade-off becomes visible.
4–5
Five million dead tuples is a substantial amount of bloat to tolerate before autovacuum acts, for a table under heavy write load.
7–8
Lowering the scale factor for this specific table trades more frequent (but individually cheaper) vacuum runs for less accumulated bloat at any given time.
Output
 estimated_rows 
----------------
       50000000

(default threshold: 5,000,050 dead tuples)
(tuned threshold: 500,050 dead tuples)

Why this works: The default scale factor is a reasonable, safe default across a huge variety of table sizes and workloads, but it is not automatically correct for every table — a large, heavily-updated table specifically is a common case where the default's proportional threshold is too permissive, and per-table tuning (not changing the global default) is the standard, targeted fix.

Lowering autovacuum_vacuum_scale_factor globally to fix one hot table

Wrong

sql
-- one large, hot table needs more aggressive vacuuming --
-- "fix" applied globally in postgresql.conf:
ALTER SYSTEM SET autovacuum_vacuum_scale_factor = 0.01;
SELECT pg_reload_conf();
-- now EVERY table in the database vacuums 10x more often than before,
-- including thousands of small, quiet tables that never needed it

Better

sql
-- tune the ONE table that actually needs it:
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01);
-- every other table keeps the sensible global default

What you see: After the global change, autovacuum activity increases dramatically across the entire database, consuming far more I/O and CPU than expected, because thousands of tables that never needed more aggressive vacuuming are now all being vacuumed far more often too.

Why: A per-table storage parameter override exists specifically so one table's unusual workload does not force a database-wide policy change — the global default is a reasonable choice for the typical table, and per-table overrides are the intended, precise tool for the exceptional ones.

The formula, worked out for two table sizes

The formula, worked out for two table sizes
Table sizeThreshold (50 + 0.1 × rows)Dead tuples needed to trigger
1,000 rows50 + 0.1 × 1,000150
10,000,000 rows50 + 0.1 × 10,000,000~1,000,050

Remember: Autovacuum's trigger point is threshold = base threshold + scale factor × table size, applied separately to vacuum and analyze — a small base floor keeps tiny tables responsive, while the size-proportional scale factor keeps huge tables from vacuuming constantly. Tune scale factor/threshold PER TABLE for an exceptional hot table, never globally for one table's needs.

See also: autovacuum workers · table and index bloat

Advertisement