Snowflake World Tour hits your city

See how leading teams deploy agents at scale. Find a stop near you. Register free.

MVCC and Dead Tuples

When someone asks why their Postgres table is 10x larger than the actual data it holds, the answer almost always starts here: MVCC.

Postgres uses Multi-Version Concurrency Control to let readers and writers operate simultaneously without blocking each other. The mechanism is elegant — every UPDATE creates a brand-new row version, and every DELETE marks a row as invisible rather than actually removing it. Concurrent transactions that started before the change can still see the old version. Nobody waits for a lock.

The tradeoff? Those old row versions — called dead tuples — stick around until vacuum cleans them up.

Here's how dead tuples accumulate:

-- A simple UPDATE creates a dead tuple
UPDATE orders SET status = 'shipped' WHERE order_id = 42;
-- The old row (status = 'pending') is now dead but still physically on disk

-- A DELETE also creates a dead tuple
DELETE FROM sessions WHERE expires_at < now();
-- Those rows are invisible but still consuming space

To see how many dead tuples are piling up across your tables:

SELECT
  schemaname || '.' || relname AS table_name,
  n_live_tup,
  n_dead_tup,
  round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct,
  last_autovacuum,
  last_vacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC
LIMIT 15;

Example output on a busy system:

      table_name       | n_live_tup | n_dead_tup | dead_pct |      last_autovacuum
-----------------------+------------+------------+----------+---------------------------
 public.events         |   4823901  |    892410  |   15.6   | 2025-01-15 03:22:14+00
 public.sessions       |    312044  |    187220  |   37.5   | 2025-01-15 03:18:01+00
 public.audit_log      |   1205890  |     54320  |    4.3   | 2025-01-15 02:45:33+00
 public.orders         |    890122  |     12045  |    1.3   | 2025-01-15 03:20:55+00

That sessions table at 37.5% dead tuples? That's a problem. It means more than a third of the physical storage is occupied by invisible garbage, and sequential scans have to skip over all of it.

Dead tuples cause three concrete problems:

  1. Wasted disk space — the table file grows without the live data growing
  2. Slower sequential scans — Postgres must read dead tuples from disk even though they're invisible
  3. Index bloat — indexes still point to dead tuples until vacuum removes the entries

How Vacuum Works

Regular VACUUM is Postgres's garbage collector. It scans a table, identifies dead tuples that no running transaction needs anymore, and marks that space as reusable for future inserts and updates. Critically, it does NOT return space to the operating system — the table file stays the same size on disk, but the space inside becomes available.

The key properties of regular VACUUM:

  • Does not lock the table — reads and writes continue normally
  • Does not shrink the file — reclaimed space is reused internally
  • Freezes old transaction IDs — prevents wraparound (more on this later)
  • Updates the visibility map — enables index-only scans
-- Run vacuum on a specific table
VACUUM orders;

-- Vacuum and update planner statistics in one pass
VACUUM ANALYZE orders;

-- See exactly what vacuum is doing
VACUUM VERBOSE orders;

The VERBOSE output tells you exactly what happened:

INFO:  vacuuming "public.orders"
INFO:  table "orders": found 12045 removable, 890122 nonremovable row versions
       in 8823 out of 9150 pages
DETAIL:  0 dead row versions cannot be removed yet, oldest xmin: 4582901
         12045 rows are dead but not yet removable
         CPU: user: 0.18 s, system: 0.05 s, elapsed: 0.42 s

This tells you vacuum found and reclaimed 12,045 dead tuples across 8,823 pages. The table is 9,150 pages total, so vacuum scanned most of it.

HOT Updates: Reducing the Vacuum Workload

Postgres has an optimization called HOT (Heap Only Tuple) updates. When an UPDATE modifies only non-indexed columns and the new tuple fits on the same heap page, Postgres can avoid creating a new index entry entirely. The old tuple is marked with a redirect pointer to the new one.

HOT updates are significant because they're cleaned up during normal page access — they don't always need a full vacuum pass. You can encourage HOT updates by setting a table's fill factor below 100:

-- Reserve 20% of each page for HOT updates
ALTER TABLE orders SET (fillfactor = 80);

-- Check HOT update ratio for your tables
SELECT
  schemaname || '.' || relname AS table_name,
  n_tup_upd,
  n_tup_hot_upd,
  round(n_tup_hot_upd::numeric / nullif(n_tup_upd, 0) * 100, 1) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 100
ORDER BY n_tup_upd DESC
LIMIT 10;
     table_name     | n_tup_upd | n_tup_hot_upd | hot_pct
--------------------+-----------+---------------+---------
 public.sessions    |   2841022 |       2105600 |   74.1
 public.orders      |    654890 |        412300 |   63.0
 public.users       |    123456 |         98700 |   79.9

A hot_pct above 70% is excellent. For update-heavy tables where you're primarily changing non-indexed columns, setting fillfactor to 70-90 can dramatically reduce vacuum workload and index bloat.

Autovacuum Configuration

Like most engineering-related questions, the answer to "how often should vacuum run?" is: it depends. That's why Postgres has autovacuum — a background process that triggers vacuum automatically based on table activity.

The Trigger Formula

Autovacuum launches a vacuum when:

dead_tuples > autovacuum_vacuum_threshold + (autovacuum_vacuum_scale_factor * n_live_tup)

With the defaults (threshold = 50, scale_factor = 0.2):

Table Size (live rows)Vacuum Triggers After Dead Tuples
1,000250
10,0002,050
100,00020,050
1,000,000200,050
10,000,0002,000,050

See the problem? For a 10M-row table, autovacuum waits until 2 million dead tuples pile up. That's often too late — the table has already bloated significantly.

Tuning for High-Churn Tables

For large, frequently-updated tables, lower the scale factor:

-- Trigger vacuum after 1% dead tuples instead of 20%
ALTER TABLE events SET (
  autovacuum_vacuum_scale_factor = 0.01,
  autovacuum_vacuum_threshold = 1000
);

-- For a table with 10M rows, this triggers at ~101,000 dead tuples
-- instead of the default ~2,000,050

Cost-Based Throttling

Autovacuum throttles itself to avoid overwhelming I/O. The key settings:

-- Show current autovacuum cost settings
SELECT name, setting, short_desc
FROM pg_settings
WHERE name LIKE 'autovacuum%'
ORDER BY name;
              name                | setting |                 short_desc
----------------------------------+---------+---------------------------------------------
 autovacuum                       | on      | Starts the autovacuum subprocess.
 autovacuum_max_workers           | 3       | Sets the maximum number of autovacuum processes.
 autovacuum_naptime               | 60      | Time between autovacuum runs.
 autovacuum_vacuum_cost_delay     | 2       | Cost delay for autovacuum (ms).
 autovacuum_vacuum_cost_limit     | -1      | Cost limit for autovacuum (-1 uses vacuum_cost_limit).
 autovacuum_vacuum_scale_factor   | 0.2     | Fraction of table size before autovacuum.
 autovacuum_vacuum_threshold      | 50      | Minimum number of dead tuples before autovacuum.

On systems with fast storage (SSDs, NVMe), you can often make autovacuum more aggressive:

-- Per-table: let autovacuum work harder on this table
ALTER TABLE events SET (
  autovacuum_vacuum_cost_delay = 0,
  autovacuum_vacuum_cost_limit = 10000
);

Setting cost_delay to 0 removes the sleep between vacuum I/O operations. This makes vacuum faster but uses more I/O bandwidth. On modern SSDs this is usually fine — on spinning disks, be more conservative.

aside positive Snowflake Postgres tunes autovacuum automatically based on your workload patterns. The platform monitors table bloat, dead tuple ratios, and transaction ID age — adjusting vacuum aggressiveness without manual intervention. You can observe vacuum activity through Snowflake Postgres Insights.

VACUUM FULL vs Regular VACUUM

This is where people get confused. Regular VACUUM marks dead space as reusable within the table file. VACUUM FULL rewrites the entire table to a new file, physically compacting it and releasing space to the OS.

Regular VACUUMVACUUM FULL
Locks table?No (concurrent reads/writes OK)Yes — ACCESS EXCLUSIVE lock
Shrinks file on disk?NoYes
How long?Seconds to minutesMinutes to hours (proportional to table size)
Safe for production?Yes — run it constantlyRarely — blocks all access
Space requirementMinimalNeeds free disk space equal to table size
-- Regular vacuum: safe, non-blocking
VACUUM orders;

-- VACUUM FULL: blocks everything, use only when desperate
VACUUM FULL orders;
-- All queries against 'orders' will block until this completes!

When to Consider VACUUM FULL

In most production environments, you should never need VACUUM FULL. Regular vacuum keeps pace with normal workloads. Consider VACUUM FULL only when:

  1. A table has extreme bloat (50%+ dead space) from a one-time bulk delete
  2. You've verified with pgstattuple that the bloat is real, not just a statistics artifact
  3. You can tolerate downtime on that table
-- Check actual bloat with pgstattuple
CREATE EXTENSION IF NOT EXISTS pgstattuple;

SELECT
  table_len,
  tuple_count,
  dead_tuple_count,
  round(dead_tuple_len::numeric / table_len * 100, 1) AS dead_pct,
  round(free_space::numeric / table_len * 100, 1) AS free_pct
FROM pgstattuple('orders');
 table_len  | tuple_count | dead_tuple_count | dead_pct | free_pct
------------+-------------+------------------+----------+----------
 7495680000 |      890122 |            12045 |      0.1 |     42.3

That 42.3% free_pct means vacuum already reclaimed space internally — it's reusable. This table doesn't need VACUUM FULL.

Alternatives to VACUUM FULL

For tables that genuinely need physical compaction without downtime, consider pg_repack:

-- pg_repack: online table rewrite without exclusive locks
-- (run from command line, not SQL — requires extension installed)
-- pg_repack --table orders --no-superuser-check -d mydb

pg_repack creates a new copy of the table in the background, replays changes, then swaps in an instant lock-swap operation. Much safer for production.

Transaction ID Wraparound

This is the scenario that keeps DBAs up at night. Postgres assigns a 32-bit transaction ID (txid) to every write transaction. That's approximately 4.2 billion IDs, with a circular comparison where about 2 billion are "in the past" and 2 billion are "in the future."

If a table has unfrozen transaction IDs that are approaching 2 billion transactions old, those transactions could wrap around and suddenly appear to be "in the future" — making committed data invisible. To prevent data corruption, Postgres will shut itself down entirely rather than let this happen.

This is not theoretical — it happens in production when vacuum can't keep up.

How Vacuum Prevents Wraparound

During a normal vacuum pass, Postgres freezes old transaction IDs — replacing the specific txid with a special "frozen" marker that means "definitely in the past, visible to everyone." This resets the clock on that tuple.

-- Check transaction ID age for each database
SELECT
  datname,
  age(datfrozenxid) AS xid_age,
  round(age(datfrozenxid)::numeric / 2000000000 * 100, 1) AS pct_to_wraparound
FROM pg_database
ORDER BY age(datfrozenxid) DESC;
  datname   |  xid_age   | pct_to_wraparound
------------+------------+-------------------
 analytics  |  189234012 |               9.5
 production |   45002341 |               2.3
 template1  |   45002300 |               2.3

The Danger Thresholds

xid_ageSituation
< 50MHealthy — nothing to worry about
50M-150MNormal — autovacuum handles this fine
150M-200MApproaching aggressive threshold
> 200Mautovacuum_freeze_max_age triggers aggressive vacuum
> 1.2BWARNING territory — investigate immediately
> 2BDatabase shutdown imminent

When age(datfrozenxid) exceeds autovacuum_freeze_max_age (default: 200 million), Postgres launches an aggressive autovacuum that scans every page in the table — even those marked as all-visible in the visibility map. This is more expensive than regular autovacuum but necessary to freeze old txids.

-- Find tables closest to triggering aggressive vacuum
SELECT
  schemaname || '.' || relname AS table_name,
  age(relfrozenxid) AS xid_age,
  pg_size_pretty(pg_total_relation_size(oid)) AS total_size
FROM pg_class
WHERE relkind = 'r'
  AND age(relfrozenxid) > 100000000
ORDER BY age(relfrozenxid) DESC
LIMIT 10;

What Blocks Vacuum from Freezing

The most common cause of wraparound emergencies? Long-running transactions. Vacuum cannot freeze any transaction ID newer than the oldest active transaction in the system.

-- Find long-running transactions that may block vacuum
SELECT
  pid,
  now() - xact_start AS xact_duration,
  state,
  left(query, 60) AS query_preview
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND state != 'idle'
ORDER BY xact_start ASC
LIMIT 5;

An idle-in-transaction connection that's been open for hours — or days — can single-handedly prevent vacuum from doing its job across the entire database.

Monitoring Vacuum Activity

In production, you don't run vacuum manually — you monitor autovacuum and intervene when it falls behind. Here are the queries that matter.

Currently Running Vacuums

-- What's vacuum doing right now?
SELECT
  p.pid,
  a.query,
  p.datname,
  p.relid::regclass AS table_name,
  p.phase,
  p.heap_blks_total,
  p.heap_blks_scanned,
  round(p.heap_blks_scanned::numeric / nullif(p.heap_blks_total, 0) * 100, 1) AS pct_done,
  p.num_dead_tuples
FROM pg_stat_progress_vacuum p
JOIN pg_stat_activity a ON a.pid = p.pid;
  pid  |     table_name     |     phase      | heap_blks_total | heap_blks_scanned | pct_done | num_dead_tuples
-------+--------------------+----------------+-----------------+-------------------+----------+-----------------
 14523 | public.events      | scanning heap  |         1203450 |            892100 |     74.1 |          234501

Tables That Need Attention

-- Tables where autovacuum is falling behind
SELECT
  schemaname || '.' || relname AS table_name,
  n_live_tup,
  n_dead_tup,
  round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct,
  last_autovacuum,
  last_autoanalyze,
  autovacuum_count,
  pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
  AND round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) > 10
ORDER BY n_dead_tup DESC;

When to Worry

These are the warning signs that vacuum needs attention:

SignalSeverityAction
dead_pct > 20% on large tablesMediumCheck if autovacuum is running, reduce scale_factor
Autovacuum running constantly but not keeping upHighIncrease autovacuum_max_workers, reduce cost_delay
age(datfrozenxid) > 500MHighCheck for long-running transactions, run manual vacuum freeze
age(datfrozenxid) > 1BCriticalEmergency — may need to cancel blocking queries and vacuum immediately
Table size growing while live rows stay flatMediumBloat accumulating — check vacuum effectiveness

A Comprehensive Health Check

-- One query to rule them all: overall vacuum health
WITH vacuum_stats AS (
  SELECT
    schemaname || '.' || relname AS table_name,
    pg_total_relation_size(relid) AS total_bytes,
    n_live_tup,
    n_dead_tup,
    n_tup_upd + n_tup_del AS churn,
    last_autovacuum,
    autovacuum_count,
    age(relfrozenxid) AS xid_age
  FROM pg_stat_user_tables
  JOIN pg_class ON pg_class.oid = relid
  WHERE n_live_tup > 1000
)
SELECT
  table_name,
  pg_size_pretty(total_bytes) AS size,
  n_live_tup,
  n_dead_tup,
  round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) AS dead_pct,
  churn,
  autovacuum_count AS vacuums,
  xid_age,
  CASE
    WHEN round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) > 20 THEN 'BLOATED'
    WHEN xid_age > 500000000 THEN 'XID_WARNING'
    WHEN last_autovacuum IS NULL AND n_dead_tup > 10000 THEN 'NEVER_VACUUMED'
    ELSE 'OK'
  END AS status
FROM vacuum_stats
ORDER BY
  CASE
    WHEN round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0) * 100, 1) > 20 THEN 1
    WHEN xid_age > 500000000 THEN 2
    ELSE 3
  END,
  n_dead_tup DESC
LIMIT 20;

This gives you a single view of which tables need immediate attention and why.

Conclusion

Understanding MVCC and vacuum is fundamental to running healthy Postgres in production. Dead tuples are a natural consequence of Postgres's concurrency model — they're not a bug, they're a feature that needs maintenance. The key takeaways: autovacuum handles most workloads when properly tuned, VACUUM FULL is almost never the right answer, and transaction ID wraparound is preventable but dangerous if ignored.

Related Resources

Deep dive into autovacuum, bloat, and tuning strategies.


Official PostgreSQL docs on autovacuum configuration and maintenance strategies.


Reduce vacuum workload with HOT updates and fill factor tuning.


Monitor vacuum activity and table bloat with Snowflake Postgres Insights.

Updated 2026-07-08

This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances