Skip to content

PostgreSQL MVCC and VACUUM Explained

Core Concept LearningOctober 22, 202616 min read

PostgreSQL never overwrites a row in place. An UPDATE writes a new version of the row and marks the old one as replaced; a DELETE only marks the row. That design, multiversion concurrency control (MVCC), is why readers never wait for writers: each query sees the versions that were current for its snapshot. The cost is garbage. Old versions stay in the table until something removes them, and that something is VACUUM.

Most Postgres performance surprises trace back here: tables that grow without more data, queries that slow down over months, an autovacuum that "runs all the time", and, in the worst case, a database that stops accepting writes to avoid transaction ID wraparound. This guide explains the mechanism from the row header up, with output from experiments run on PostgreSQL 18.6, and checks every threshold against the PostgreSQL 18 documentation.

For the isolation levels that MVCC implements, see ACID and Isolation Levels Explained.

The life of a Postgres row: an UPDATE writes a new version and stamps the old one with xmax; the old version becomes dead once no snapshot can see it; VACUUM makes its space reusable; old live rows are eventually frozen
The life of a Postgres row: an UPDATE writes a new version and stamps the old one with xmax; the old version becomes dead once no snapshot can see it; VACUUM makes its space reusable; old live rows are eventually frozen

Row versions: xmin, xmax and ctid

Every row version (a tuple) starts with a fixed header of 23 bytes on most machines. Three fields in it implement MVCC:

  • t_xmin: the ID of the transaction that inserted this version.
  • t_xmax: the ID of the transaction that deleted or replaced it, or 0 if none has.
  • t_ctid: the location of this version, or of the newer version that replaced it.

You can watch them change. On PostgreSQL 18.6, inserting a row into an accounts table in transaction 755 gave a version with xmin = 755, xmax = 0 at location (0,1) (page 0, item 1). Then UPDATE accounts SET balance = 90 ran in transaction 756. Reading the raw page with the pageinspect extension showed two versions side by side:

  • Item 1: xmin 755, xmax 756, ctid (0,2). This is the old version, stamped as replaced by 756 and pointing to its successor.
  • Item 2: xmin 756, xmax 0, ctid (0,2). This is the new, current version.

A DELETE in transaction 757 simply set item 2's xmax to 757. Nothing was erased; both versions were still on the page. When a query reads the table, Postgres checks each version's xmin and xmax against the query's snapshot, the set of transactions that had committed when it started, and returns only the version that snapshot should see.

Page 0 of the accounts table after one UPDATE, read with pageinspect on PostgreSQL 18: the old version has xmax 756 and points to the new version at (0,2)
Page 0 of the accounts table after one UPDATE, read with pageinspect on PostgreSQL 18: the old version has xmax 756 and points to the new version at (0,2)

Quick reference

  • You can select the hidden system columns directly: SELECT xmin, xmax, ctid, * FROM accounts;.
  • pageinspect (CREATE EXTENSION pageinspect;) needs superuser rights; use it on a test database, not production.
  • Transaction IDs are only assigned to transactions that write; read-only transactions do not consume them.

Remember this

Each row version records which transaction created it and which replaced it, and visibility is decided per snapshot from those two numbers.

Why readers never block writers

Because an update leaves the old version in place, a transaction that started earlier can keep reading it while the update proceeds. The writer creates version 2; the reader's snapshot says transaction 756 had not committed when it started, so it still sees version 1. Neither waits for the other.

How long a transaction keeps a snapshot depends on the isolation level:

  • Read Committed (the default) takes a new snapshot for each statement, so each statement sees all changes committed before it began.
  • Repeatable Read and Serializable take one snapshot at the transaction's first statement and keep it for the whole transaction.

That second point is the root of most VACUUM problems. A version can only be removed once no running transaction's snapshot could still see it. Postgres tracks the oldest such snapshot in the whole cluster, the xmin horizon. Any version replaced after that point must stay, in every table, however busy. One long transaction, even an idle one, holds the horizon back for everybody.

Writers still block writers: two transactions updating the same row take turns, because the second must wait to see whether the first commits before it can decide what to update.

Quick reference

  • idle in transaction sessions, often from application code that forgot to commit, hold their snapshot until they end; idle_in_transaction_session_timeout limits them.
  • On a primary with hot_standby_feedback enabled, long queries on a replica also hold back the primary's horizon.
  • The isolation anomalies each level allows are covered in ACID and Isolation Levels Explained.

Remember this

Snapshots let readers use old versions, so cleanup must wait for the oldest snapshot anywhere in the cluster.

Dead tuples and table bloat

A version that no snapshot can see any more is dead. Dead tuples still occupy space in the table's pages and still have index entries pointing at them, until vacuum removes them.

The experiment: a table of 100,000 rows with autovacuum disabled took 12 MB. A single UPDATE orders SET status = 'paid' on every row wrote 100,000 new versions. pg_stat_user_tables then reported 100,000 dead tuples, and the table file had doubled to 24 MB with no more live data than before.

Dead tuples cost more than disk space:

  • Sequential scans read them. A scan of the 24 MB table reads twice the pages for the same rows.
  • Indexes grow. Each new version that is not a HOT update (see below) needs new index entries, and the old entries remain until vacuum (or B-tree cleanup during later inserts) removes them.
  • Caches hold less useful data, because pages are half full of rows nobody can see.

Bloat is the long-term version: space inside the table that is free or dead, beyond what the workload needs. Some free space is healthy, since new versions can use it without growing the file. Bloat becomes a problem when vacuum falls behind and the file keeps growing.

Quick reference

  • n_dead_tup in pg_stat_user_tables is an estimate maintained by the statistics system; use it for trends, not exact counts.
  • The pgstattuple extension measures exact dead and free space in a table, at the cost of reading it all.
  • Updates that touch every row (backfills, migrations) double a table's size at once; run them in batches with vacuum in between.

Remember this

Every update leaves a dead version behind, and until vacuum removes it, it costs disk, scans, index size and cache.

What VACUUM does, and what it does not

Standard VACUUM scans the table, removes dead versions and their index entries, and records the freed space in the free space map so future inserts and updates can reuse it. It runs alongside normal reads and writes. It also updates the visibility map, which marks pages whose rows are all visible to everyone. Index-only scans and later vacuums use that map to skip work.

What it does not do is shrink the file. The PostgreSQL documentation says standard VACUUM will not return space to the operating system, except when pages at the end of the table become entirely free and it can briefly take an exclusive lock to truncate them. The experiments showed both cases. After deleting the only row in a small table, VACUUM truncated it to zero pages. After the 100,000-row update, the second VACUUM reported tuples: 100000 removed, yet the file stayed at 24 MB. The space was reusable but not returned.

The experiment also showed the blocker. With a Repeatable Read transaction that had started before the update still open, VACUUM VERBOSE reported 100000 are dead but not yet removable. That transaction's snapshot could still see the old versions. Once it committed, the next VACUUM removed all of them.

VACUUM FULL rewrites the table into a new file without the dead space. In the experiment it brought the file back to 12 MB. The documentation warns that it is much slower and takes an ACCESS EXCLUSIVE lock on the table, which blocks all reads and writes. It also needs extra disk space for the new copy while it runs. The documentation's goal for routine maintenance is to vacuum often enough that VACUUM FULL is never needed.

Measured on PostgreSQL 18 with 100,000 updated rows: while an older snapshot stayed open, VACUUM could not remove any dead versions; after it committed, VACUUM removed all 100,000 but the file stayed 24 MB until VACUUM FULL rewrote it to 12 MB
Measured on PostgreSQL 18 with 100,000 updated rows: while an older snapshot stayed open, VACUUM could not remove any dead versions; after it committed, VACUUM removed all 100,000 but the file stayed 24 MB until VACUUM FULL rewrote it to 12 MB

Quick reference

  • VACUUM (VERBOSE) prints the removable cutoff and dead-but-not-removable counts; it is the fastest way to see whether something is holding back cleanup.
  • For a table whose whole contents are deleted periodically, the docs recommend TRUNCATE instead of DELETE plus VACUUM.
  • Tools such as pg_repack rebuild bloated tables with shorter exclusive locks than VACUUM FULL; test them before relying on them.

Remember this

VACUUM makes dead space reusable without shrinking the file, and it can only remove versions older than the oldest open snapshot.

HOT updates: avoiding index work

A heap-only tuple (HOT) update avoids some of MVCC's cost. The PostgreSQL docs list two conditions:

  • The update does not change any column used by the table's indexes (BRIN indexes excepted).
  • The page holding the old version has enough free space for the new one.

When both hold, the new version goes on the same page and no new index entries are written. Older versions in the chain can be removed during normal page access, even by a SELECT, without waiting for vacuum.

The second condition is the one you control. By default, tables are filled to 100%, so a freshly loaded table has no room for new versions on the same page. The experiment loaded two identical 100,000-row tables and updated a non-indexed status column in every row:

  • Default fill factor (100): 0 of 100,000 updates were HOT.
  • fillfactor = 70: 41,306 of 100,000 updates were HOT, for a heap of 27 MB instead of 24 MB.

Leaving 30% of each page free made about 41% of the updates HOT on the first pass. A lower fill factor costs space up front and makes the table larger to scan. It suits tables with frequent updates to non-indexed columns, such as status flags and counters.

Quick reference

  • Compare n_tup_hot_upd with n_tup_upd in pg_stat_user_tables to see your HOT ratio per table.
  • Adding an index on a frequently updated column turns its updates from HOT into non-HOT; check the HOT ratio before and after.
  • Set fill factor with ALTER TABLE ... SET (fillfactor = 70); it affects new pages, so existing pages only change after a rewrite.

Remember this

HOT updates skip new index entries when no indexed column changes and the page has room; a lower fill factor creates that room.

Autovacuum: when it runs and how to tune it

Autovacuum is on by default and should stay on. A launcher process starts a worker in each database every autovacuum_naptime (default 1 minute), with at most autovacuum_max_workers (default 3) running at once. A table is vacuumed when its obsolete tuples since the last vacuum exceed the vacuum threshold:

vacuum threshold = min(autovacuum_vacuum_max_threshold, autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × reltuples)

With the PostgreSQL 18 defaults of 50, 0.2 and 100,000,000:

  • 1 million rows: 50 + 0.2 × 1,000,000 = 200,050 dead tuples before autovacuum starts.
  • 100 million rows: 50 + 0.2 × 100,000,000 ≈ 20 million.
  • 1 billion rows: 50 + 0.2 × 1,000,000,000 ≈ 200 million, capped at 100 million by autovacuum_vacuum_max_threshold, which is new in PostgreSQL 18.

Large tables are where the scale factor hurts: waiting for 20% of a big table to be dead means vacuum runs rarely and has a lot to do when it does. The usual fix is per-table settings, for example ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01). Inserts trigger vacuums too: a separate threshold of 1,000 tuples plus 0.2 of the unfrozen part of the table, which matters for append-only tables that never update.

Autovacuum is also throttled. It sleeps 2 ms (autovacuum_vacuum_cost_delay) each time it accumulates 200 cost units (vacuum_cost_limit). The limit is shared across all running workers, so adding workers does not add throughput unless you raise the limit too. On busy systems, raising the cost limit is often the most useful single change.

Quick reference

  • PostgreSQL 18 also adds autovacuum_worker_slots (default typically 16), so autovacuum_max_workers can be raised without a restart up to that number.
  • Autovacuum yields to conflicting locks, but the docs warn that regularly running commands that conflict with its lock can stop it from ever finishing.
  • Autovacuum does not process temporary tables, and it does not run ANALYZE on partitioned parent tables; analyze those yourself.

Remember this

Autovacuum starts at 20% dead rows by default, which is too late for large tables; lower the per-table scale factor and raise the cost limit.

Transaction ID wraparound and freezing

Transaction IDs are 32 bits and compared with modulo-2³² arithmetic. For any XID, the two billion before it are in the past and the two billion after it are in the future. The documentation spells out the consequence: if a row version survived more than about two billion transactions, it would suddenly appear to be in the future, and therefore invisible. To prevent that, every table must be vacuumed at least once every two billion transactions.

What vacuum does about it is freezing. A frozen row version is treated as older than every normal XID, so it stays visible however far the counter moves. Each table records its oldest unfrozen XID in pg_class.relfrozenxid, and several settings act on its age:

  • vacuum_freeze_min_age (50 million): rows older than this are frozen when vacuum visits their page.
  • vacuum_freeze_table_age (150 million): vacuum does an aggressive scan of every page that might hold unfrozen XIDs.
  • autovacuum_freeze_max_age (200 million): an anti-wraparound autovacuum is forced, even if autovacuum is disabled, and it does not yield to conflicting locks.
  • vacuum_failsafe_age (1.6 billion): vacuum drops its throttling and skips non-essential work such as index vacuuming to finish faster.

If all that fails, Postgres warns once a database is within 40 million transactions of wraparound. Within 3 million, it refuses commands that assign new XIDs, which means every write. Recovery, per the docs: resolve old prepared transactions, end long-running transactions, drop stale replication slots, then run VACUUM as a superuser. Do not use VACUUM FULL, which needs an XID itself.

Table XID age thresholds on a linear scale to 2 billion: aggressive scans from 150 million, forced anti-wraparound autovacuum at 200 million, the failsafe at 1.6 billion, warnings 40 million before wraparound and refusal of new XIDs 3 million before
Table XID age thresholds on a linear scale to 2 billion: aggressive scans from 150 million, forced anti-wraparound autovacuum at 200 million, the failsafe at 1.6 billion, warnings 40 million before wraparound and refusal of new XIDs 3 million before

Quick reference

  • Multixact IDs, used when several transactions lock the same row, have their own wraparound limits and settings, with the forced-vacuum default at 400 million.
  • An anti-wraparound autovacuum shows (to prevent wraparound) in pg_stat_activity; seeing it is normal, seeing it run for days is not.
  • XID consumption rate decides how much time you have: a system using 10,000 write transactions a second consumes 200 million XIDs in under six hours.

Remember this

Vacuum must freeze old rows before the 32-bit XID counter wraps; monitor relfrozenxid age long before Postgres starts refusing writes.

What to monitor

These queries cover the four questions that matter: which tables need vacuum, what is holding cleanup back, and how close each table is to a forced freeze. They were run on PostgreSQL 18.6:

1-- 1. Tables with the most dead tuples, and when autovacuum last ran2SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, autovacuum_count3FROM pg_stat_user_tables4ORDER BY n_dead_tup DESC5LIMIT 10;6 7-- 2. Sessions holding back cleanup: the oldest snapshots8SELECT pid, state, age(backend_xmin) AS xmin_age, now() - xact_start AS open_for, left(query, 60) AS query9FROM pg_stat_activity10WHERE backend_xmin IS NOT NULL11ORDER BY age(backend_xmin) DESC12LIMIT 5;13 14-- 3. Other things that hold the horizon: replication slots and prepared transactions15SELECT slot_name, active, age(xmin) AS xmin_age, age(catalog_xmin) AS catalog_xmin_age FROM pg_replication_slots;16SELECT gid, prepared, age(transaction) AS xid_age FROM pg_prepared_xacts;17 18-- 4. Wraparound distance: oldest unfrozen XID per table19SELECT c.oid::regclass AS table_name, age(c.relfrozenxid) AS xid_age20FROM pg_class c21WHERE c.relkind IN ('r', 'm', 't')22ORDER BY age(c.relfrozenxid) DESC23LIMIT 5;

What to alert on:

  • Dead tuples growing faster than autovacuum clears them in a hot table, or last_autovacuum far in the past for a table that changes.
  • Any session whose backend_xmin age keeps growing, especially idle in transaction. This is the most common cause of bloat across a whole cluster.
  • Inactive replication slots with a large xmin age. A slot for a replica that no longer exists holds the horizon forever.
  • age(relfrozenxid) approaching autovacuum_freeze_max_age and staying there. A healthy table is frozen and its age drops; a table that stays near the limit has a vacuum that is not finishing.

None of these require extensions, and all are cheap enough to run every minute from a monitoring system.

Quick reference

  • pg_stat_progress_vacuum shows each running vacuum's phase and progress, which tells you whether a long vacuum is moving.
  • Log autovacuum runs that take longer than a threshold with log_autovacuum_min_duration to see what it spends time on.
  • For slow queries that bloat can cause, see Optimizing PostgreSQL Query Performance with EXPLAIN ANALYZE.

Remember this

Watch dead tuples, the oldest snapshot, replication slots and relfrozenxid age; each one points to a different cause.

Practice: reproduce the experiments

Run the experiments in this guide on a disposable PostgreSQL 18 container (docker run -e POSTGRES_PASSWORD=pw -p 5432:5432 postgres:18):

1. Create a table with autovacuum_enabled = off, insert one row, update it, and read the page with heap_page_items(get_raw_page('accounts', 0)) to see both versions' t_xmin, t_xmax and t_ctid. 2. Load 100,000 rows, update every row, and compare pg_relation_size and n_dead_tup before and after. 3. In a second session, start BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM orders; and leave it open. Update every row again and run VACUUM (VERBOSE) orders; find the "dead but not yet removable" count. 4. Commit the second session, vacuum again, and confirm the dead tuples are removed but the file size is unchanged. Then run VACUUM FULL and measure again. 5. Repeat the update on two tables with fillfactor 100 and 70, and compare n_tup_hot_upd. 6. Compute the autovacuum trigger point for your largest table from the formula, and run the four monitoring queries.

Pass criterion: you can show an old snapshot blocking cleanup and point to it in pg_stat_activity, and your HOT ratio rises with the lower fill factor.

Quick reference

  • Disabling autovacuum per table keeps the experiment deterministic; never do it in production.
  • The XIDs you see will differ from this guide's; the relationships between them will not.
  • Write down the size, dead-tuple and HOT numbers at each step; the differences are the lesson.

Remember this

Seeing an open snapshot block VACUUM on your own machine makes the most common production bloat problem obvious.

Key takeaway

Postgres implements MVCC by never updating in place. Every row version records the transaction that created it (xmin) and the one that replaced it (xmax), and each query's snapshot decides which versions it sees. That is why readers never block writers. It is also why every update and delete leaves a dead version behind, which costs space, scan time and index entries until VACUUM removes it. VACUUM can only remove versions older than the oldest open snapshot, and it makes space reusable rather than shrinking the file. VACUUM FULL shrinks it, at the cost of an exclusive lock.

Autovacuum does this work automatically. Its default 20% scale factor is too late for large tables, so tune it per table and give it enough cost budget. HOT updates and a lower fill factor reduce the work for tables with frequent small updates. Above all, vacuum must freeze old rows before the 32-bit transaction counter wraps. Monitor relfrozenxid age, long-running transactions and stale replication slots so you find out early. For how isolation levels use these snapshots, see ACID and Isolation Levels Explained; for diagnosing the slow queries bloat causes, see Optimizing PostgreSQL Query Performance.

Practice (60 min): reproduce the six experiments above on a disposable PostgreSQL 18 container.

Share:

References

  1. PostgreSQL 18 docs: Routine Vacuuming (24.1) — checked Oct 22, 2026
  2. PostgreSQL 18 docs: Vacuuming configuration (19.10) — checked Oct 22, 2026
  3. PostgreSQL 18 docs: Concurrency Control, Introduction (13.1) — checked Oct 22, 2026
  4. PostgreSQL 18 docs: Database Page Layout (66.6) — checked Oct 22, 2026
  5. PostgreSQL 18 docs: Heap-Only Tuples (HOT) — checked Oct 22, 2026
  6. PostgreSQL 18 docs: VACUUM — checked Oct 22, 2026
SH

Shoaib Hossain

Lead Author & Systems Architect

Software engineer and distributed systems architect specializing in backend scalability, cloud-native infrastructure, databases, and AI engineering workflows. Author and maintainer of Core Concept Learning.

Human-Engineered & Fact-CheckedOriginal Visual DiagramsEditorial Standards →Send Feedback

Related Articles

At the heart of every database system lies a Storage Engine that determines how data is written to disk, indexed, and re…

Read

A selective lookup that once scanned most of a large table can become an index walk plus a few heap fetches — often the …

Read

"The index exists, why is this query still slow?" is one of the most common database escalations — and the answer is alm…

Read

Keep learning

Follow a structured path or browse all courses to go deeper.