cld-toys › Guided exercises › PostgreSQL

MVCC and Vacuum

An UPDATE is really an INSERT plus a tombstone. Watch a row physically move, watch a table triple in size without gaining a row, and watch one idle transaction stop vacuum from cleaning up anything at all.


Concept

Postgres never modifies a row in place. An UPDATE writes a brand-new copy of the row and marks the old copy dead — which is how two transactions can see two different versions of the same row at the same time (that's MVCC, the machinery underneath transaction isolation). The dead copies don't disappear on their own. They pile up as bloat, and VACUUM is the process that reclaims them. This exercise makes all of that visible.


Mental model: the vocabulary, and who cleans up

Three system columns are the whole story. Every table has them; they're just hidden from SELECT *:

ColumnWhat it is
ctid The row version's physical address: (page, line_pointer). Page 0, slot 3 is (0,3). Not a stable identifier — it changes every time the row is updated, which is the point.
xmin The transaction ID that created this row version.
xmax The transaction ID that deleted (or superseded) it. 0 means still live.
The one rule underneath all of MVCC A row version is visible to your transaction if its xmin committed before your snapshot and its xmax hasn't. That's it. An UPDATE is then just: write a new version with xmin = me, and set xmax = me on the old one. Both versions are physically present on disk; your snapshot picks which one you see.

Once no live snapshot can see a dead version, it's garbage. Four things can collect it, and they are not interchangeable:

MechanismWhat it actually doesReach for it when…
HOT pruning
(automatic, no config)
When a query happens to touch a page, Postgres opportunistically frees dead tuple data on that page. Cheap and constant, but it can't free the tuples' line pointers or their index entries, so it only recovers part of the space. Never — it's always on. Worth knowing only because it explains why VACUUM VERBOSE sometimes reports suspiciously few "tuples removed" alongside a huge "dead item identifiers removed".
autovacuum
(on by default)
A background worker that runs a normal VACUUM on a table once its dead-tuple count crosses a threshold (default: 20% of the table plus 50 rows). This is the answer in production ~95% of the time. When a table bloats anyway, the fix is almost always to make autovacuum run more aggressively on that table (autovacuum_vacuum_scale_factor), not to schedule manual vacuums.
VACUUM
(manual, non-blocking)
Marks dead tuples' space free for reuse by that same table, and clears their index entries. Takes only a SHARE UPDATE EXCLUSIVE lock — reads and writes continue normally. Does not return disk to the OS. Right after a large bulk DELETE/UPDATE, when you don't want to wait for autovacuum's threshold. Also as VACUUM ANALYZE after a bulk load, to refresh planner statistics at the same time.
VACUUM FULL
(manual, blocking)
Rewrites the entire table into a brand-new file containing only live rows, then swaps it in. Genuinely returns disk to the OS and rebuilds indexes compactly. Takes an ACCESS EXCLUSIVE lock — every reader and writer blocks — and needs room for a second full copy while it runs. Rarely, and never casually on a live system. Justified after a one-off deletion of most of a large table, during a maintenance window. If you need the space back without the downtime, pg_repack does the same job online.
The distinction that trips people up VACUUM almost never makes the table smaller. It makes the space inside the table reusable, so the table stops growing. Those are different outcomes, and Part 3 below shows both.

Setup

Two terminals, side by side. Everything below assumes you have Docker.

docker run --name pg-mvcc --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16

Terminal A:

docker exec -it pg-mvcc psql -U postgres

Terminal B (a second, independent connection — not needed until Part 4, but open it now):

docker exec -it pg-mvcc psql -U postgres

In A, create the schema:

CREATE EXTENSION pageinspect;
CREATE TABLE items (id int PRIMARY KEY, name text, qty int)
  WITH (autovacuum_enabled = off);
INSERT INTO items VALUES (1, 'widget', 10);
Why autovacuum is off pageinspect is a contrib extension (bundled with the official image) that lets you read raw heap pages — normally invisible internals. And autovacuum_enabled = off is deliberate: autovacuum would otherwise clean up behind your back mid-exercise and make the bloat you're trying to observe vanish. This is a lab setting only — never do this to a real table.

Part 1 — an UPDATE is an INSERT

What this part tests: whether an UPDATE modifies the row where it sits. Per the mental model, it can't — it writes a new version elsewhere and marks the old one superseded. ctid is the direct evidence, since it's the row version's physical address.

Step 1 · where the row lives
Session A
SELECT ctid, xmin, xmax, * FROM items;
ctid | xmin | xmax | id | name | qty -------+------+------+----+--------+----- (0,1) | 733 | 0 | 1 | widget | 10 (1 row)

Page 0, slot 1. Created by transaction 733, never superseded (xmax = 0). Your xmin will differ — transaction IDs depend on how much the cluster has done.

Step 2 · update it
Session A
UPDATE items SET qty = qty + 1 WHERE id = 1;
Predict: this is a single-row table and you changed one integer in place. Does ctid stay (0,1)? Click to check.
Session A
SELECT ctid, xmin, xmax, * FROM items;
ctid | xmin | xmax | id | name | qty -------+------+------+----+--------+----- (0,2) | 734 | 0 | 1 | widget | 11 (1 row)

The row moved — slot 1 → slot 2 — and its xmin is the new transaction. This isn't the same row edited; it's a new row version.

Step 3 · read the raw page
Session A — run that same UPDATE twice more (ctid reaches (0,4)), then look underneath:
SELECT lp AS line_ptr, lp_off, t_xmin, t_xmax, t_ctid
FROM heap_page_items(get_raw_page('items', 0));
Predict: the table contains one row. How many tuples are physically sitting on page 0? Click to check.
line_ptr | lp_off | t_xmin | t_xmax | t_ctid ----------+--------+--------+--------+-------- 1 | 8152 | 733 | 734 | (0,2) 2 | 8112 | 734 | 735 | (0,3) 3 | 8072 | 735 | 736 | (0,4) 4 | 8032 | 736 | 0 | (0,4) (4 rows)

Four tuples for one logical row. Read the t_xmax/t_ctid columns as a linked list:

lp 1 xmin 733 xmax 734 ──▶ (0,2) dead lp 2 xmin 734 xmax 735 ──▶ (0,3) dead lp 3 xmin 735 xmax 736 ──▶ (0,4) dead lp 4 xmin 736 xmax 0 ──▶ (0,4) LIVE (points at itself)

Version 1 was killed by txn 734 and points forward to (0,2); (0,2) was killed by 735 and points to (0,3); and so on until (0,4), whose t_xmax = 0 and which points at itself. That forward chain is how a transaction holding an old snapshot walks to the version it's allowed to see.

Note lp_off decreasing (8152 → 8032): new tuples are written from the end of the 8 KB page backwards, while line pointers grow from the front. A page fills when they meet.

These were HOT updates Every new version landed on the same page, which means Postgres applied the HOT optimization — available when the updated column isn't indexed and the page has room, and it skips the index write entirely. Confirm it with SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 'items';3 | 3. It matters from Part 2 on, where full-table updates fill pages, new versions are forced onto different pages, and this optimization stops applying.

Part 2 — bloat you can measure

What this part tests: whether that pile of dead versions costs anything real. Part 1 leaked three dead tuples; at scale, the same mechanism means a table can grow without gaining a single row.

Step 4 · a baseline
Session A
INSERT INTO items SELECT g, 'item-' || g, g FROM generate_series(2, 100000) g;
SELECT pg_size_pretty(pg_relation_size('items'));
pg_size_pretty ---------------- 5096 kB (1 row)

5096 kB for 100,000 rows.

Step 5 · update every row, twice
Session A
UPDATE items SET qty = qty + 1;   -- check size, then run it again
Predict: no inserts, no deletes — the row count stays exactly 100,000 throughout. What happens to the 5096 kB? Click to check.
5096 kB -- 100,000 rows 10192 kB -- after one full-table UPDATE 15 MB -- after two

Exactly linear: each UPDATE writes a full second copy of every row and the dead copies stay. SELECT count(*) still returns 100000 — the table is 3× the size for the same data. Two-thirds of it is garbage.

Session A
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'items';
n_live_tup | n_dead_tup ------------+------------ 100000 | 199997 (1 row)

These statistics are reported asynchronously — if you run this immediately after the updates you may catch stale numbers. Wait a second and re-run. The 199997, not 200000, is Part 1's three dead tuples already having been pruned opportunistically.


Part 3 — VACUUM reclaims, but doesn't shrink

What this part tests: the distinction flagged at the end of the mental model. VACUUM frees the dead space — the question is who gets it back, your table or your filesystem.

Step 6 · vacuum it
Session A
VACUUM (VERBOSE) items;
Predict: 10 MB of that 15 MB is provably dead. Does pg_relation_size drop back toward 5096 kB? Click to check.

Trimmed to the table's own output; vacuum also reports on the table's TOAST relation.

INFO: vacuuming "postgres.public.items" INFO: finished vacuuming "postgres.public.items": index scans: 1 pages: 0 removed, 1911 remain, 1911 scanned (100.00% of total) tuples: 27 removed, 100000 remain, 0 are dead but not yet removable removable cutoff: 751, which was 0 XIDs old when operation ended index scan needed: 1274 pages from table (66.67% of total) had 199970 dead item identifiers removed index "items_pkey": pages: 623 in total, 0 newly deleted, 0 currently deleted, 0 reusable
pg_size_pretty ---------------- 15 MB (1 row)

Still 15 MB. pages: 0 removed, 1911 remain is vacuum telling you outright that it handed nothing back to the OS.

Read two of those lines together and the HOT-pruning row of the mental model pays off. Only 27 tuples were removed here, because the full-table UPDATEs had already scanned every page and pruned the dead tuple bodies on the way past. What they couldn't touch was the dead tuples' line pointers, because index entries still pointed at them — freeing those requires the index pass, which is the 199970 dead item identifiers removed line. That's why vacuum needs to scan indexes at all.

Step 7 · so what did vacuum buy you?
Session A — two more full-table updates, checking the size after each:
UPDATE items SET qty = qty + 1;
Predict: the file didn't shrink, so does a third and fourth full-table UPDATE push it to 20 MB and 25 MB? Click to check.
15 MB -- after the third full-table UPDATE 15 MB -- after the fourth

Flat. The space vacuum freed was handed back to this table's free space map, and the new row versions were written into it rather than extending the file. That's the actual payoff: vacuum doesn't shrink the table, it stops the table growing.


Part 4 — the thing that stops vacuum working

What this part tests: vacuum's one hard constraint. It may only remove a dead version if no snapshot anywhere in the cluster could still need it — and a snapshot is held for as long as a transaction stays open, whether or not that transaction is doing anything. This part needs both terminals.

Step 8 · an idle transaction

Session A VACUUM items; for a clean slate. Then:

Session B
BEGIN;
SELECT count(*) FROM items;

Don't commit. Leave it sitting there. This is a completely ordinary idle-in-transaction session — an app that forgot to close one, or a developer who typed BEGIN and went to lunch.

Session A
UPDATE items SET qty = qty + 1;
VACUUM (VERBOSE) items;
Predict: A now has 100,000 fresh dead tuples, and B is only reading — it holds no lock on any of them. Does VACUUM clean them up? Click to check.
INFO: finished vacuuming "postgres.public.items": index scans: 0 pages: 0 removed, 1911 remain, 1275 scanned (66.72% of total) tuples: 0 removed, 233281 remain, 100000 are dead but not yet removable removable cutoff: 753, which was 1 XIDs old when operation ended

0 removed … 100000 are dead but not yet removable. Vacuum ran, did its full scan, and reclaimed nothing. B's snapshot predates the update, so every one of those old versions is still potentially visible to B, and vacuum is not allowed to touch them.

Step 9 · find the culprit
Session A
SELECT pid, state, xact_start, backend_xmin, left(query, 40) AS query
FROM pg_stat_activity WHERE backend_xmin IS NOT NULL;
pid | state | xact_start | backend_xmin | query -----+--------+-------------------------------+--------------+-------------------------- 123 | active | 2026-07-22 13:46:59.877228+00 | 753 | SELECT count(*) FROM items;

backend_xmin is the oldest transaction ID that backend can still see; the smallest one in the cluster is vacuum's ceiling. Compare it to vacuum's removable cutoff: 753 above — identical. That's the whole causal chain, visible in two numbers.

The production playbook This query is the answer to "why is this table bloating". Find the smallest backend_xmin, look at its xact_start, and go ask that session what it's doing.
Step 10 · writes keep coming
Session A — B is still idle in its transaction:
UPDATE items SET qty = qty + 1;   -- twice
VACUUM items;
SELECT pg_size_pretty(pg_relation_size('items'));
Predict: what happens to the table's size if writes continue while vacuum is stuck? Click to check.
pg_size_pretty ---------------- 20 MB (1 row)

It grew straight through the vacuum, because there was no reusable space to write into. This is exactly how a forgotten BEGIN turns into a disk-full page at 3 a.m. — nothing is failing, nothing is locked, the table simply grows forever.

Step 11 · release it

Session B COMMIT;

Session A
VACUUM (VERBOSE) items;
Predict: same command that reclaimed nothing 30 seconds ago. What does it do now? Click to check.
tuples: 300000 removed, 100000 remain, 0 are dead but not yet removable index scan needed: 1911 pages from table (75.00% of total) had 299778 dead item identifiers removed

All 300,000 collected the instant the blocker went away. Vacuum was never broken. Size is still 20 MB, per Part 3.


Part 5 — VACUUM FULL, and what it costs

What this part tests: the one mechanism that actually returns disk. Per the mental model, it does so by rewriting the table wholesale, which is why it's the option you reach for last.

Step 12 · before
Session A
SELECT pg_size_pretty(pg_relation_size('items')) AS heap,
       pg_size_pretty(pg_relation_size('items_pkey')) AS pkey;
SELECT ctid FROM items WHERE id = 1;
heap | pkey -------+--------- 20 MB | 6600 kB ctid ----------- (1910,97)
Step 13 · rewrite it
Session A
VACUUM FULL items;
Predict: 100,000 live rows inside a 20 MB file. Final size — and what happens to ctid? Click to check.
heap | pkey ---------+--------- 5096 kB | 2208 kB ctid ------- (0,2)
before VACUUM FULLafter
heap20 MB5096 kB
primary key index6600 kB2208 kB
row 1 lives at(1910,97)(0,2)

Back to the Part 2 baseline exactly — 20 MB → 5096 kB, and the index fell from 6600 kB to 2208 kB as a bonus, since it was rebuilt from scratch rather than incrementally cleaned. Row 1 is back at the front of page 0.

That ctid change is the tell for what just happened: this wasn't cleanup, it was a full copy into a new file. Which is also the cost — it held an ACCESS EXCLUSIVE lock the whole time (every reader blocked, not just writers), and it needed room for both copies at once. On a 200 GB table you need 200 GB free and a maintenance window.


Part 6 — vacuum isn't only about space

What this part tests: the other thing vacuum maintains. Alongside freeing tuples it updates the visibility map, which marks pages where every tuple is visible to everyone — and that map is what makes index-only scans possible.

Step 14 · dirty half the table
Session A
UPDATE items SET qty = qty + 1 WHERE id <= 50000;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
  SELECT count(*) FROM items WHERE id BETWEEN 1 AND 50000;
Aggregate (actual rows=1 loops=1) -> Bitmap Heap Scan on items (actual rows=50000 loops=1) Recheck Cond: ((id >= 1) AND (id <= 50000)) Heap Blocks: exact=639 -> Bitmap Index Scan on items_pkey (actual rows=100000 loops=1) Index Cond: ((id >= 1) AND (id <= 50000))

Two things are wrong here. The index scan returns 100000 rows to produce 50000 — the index still carries an entry for every dead version. And Postgres must visit 639 heap blocks just to check visibility, even though id is in the index and the query needs nothing else.

Step 15 · vacuum and re-plan
Session A
VACUUM items;   -- plain, not FULL
Predict: nothing about the query, the schema, or the statistics changes. Does the plan change? Click to check.
Aggregate (actual rows=1 loops=1) -> Index Only Scan using items_pkey on items (actual rows=50000 loops=1) Index Cond: ((id >= 1) AND (id <= 50000)) Heap Fetches: 0

A different plan node, 50000 index rows instead of 100000, and Heap Fetches: 0 — the table was not touched at all. Vacuum marked the pages all-visible and unlocked a strictly better access path. This is why a badly-vacuumed table gets slow, not just fat.


What you should see

An UPDATE that visibly relocates a row (ctid (0,1)(0,2)), leaving a forward-linked chain of dead versions on the page. A 100,000-row table that triples from 5096 kB to 15 MB across two full-table updates without gaining a row. A VACUUM that reclaims all of it and shrinks the file by exactly zero bytes — but stops further growth dead. A single idle BEGIN in another terminal that reduces that same VACUUM to 0 removed … 100000 are dead but not yet removable, with the table growing to 20 MB regardless. And VACUUM FULL returning it to 5096 kB by rewriting the file, at the price of locking out every reader.

Why

Because Postgres implements MVCC by keeping old row versions in the table itself. Other databases make the opposite choice — Oracle and MySQL/InnoDB update rows in place and push the old versions into a separate undo/rollback segment. Neither is free: Postgres pays with bloat and a vacuum process to manage, InnoDB pays with rollback-segment growth and slower reads for old snapshots. Postgres's choice is what makes its UPDATEs and rollbacks cheap (a rollback is nearly free — the new versions just never become visible) and what makes vacuum a permanent operational concern.

Given that design, VACUUM's "removable" test falls out with no room for cleverness: a dead version can only be dropped once no snapshot in the cluster could still need it. One idle transaction pins the horizon for the entire cluster, and vacuum's only honest response is to scan the whole table and remove nothing — which is precisely what you saw in Part 4. Not a bug, not a tuning problem, and not fixable from vacuum's side at all; the fix is always to close the transaction.

And because VACUUM only marks space free within the file, the file itself never shrinks. Handing pages back to the OS requires proving no live tuple sits in them, which in the general case means relocating live tuples — a rewrite. VACUUM FULL does exactly that rewrite, and the ACCESS EXCLUSIVE lock is the direct consequence: while rows are moving to new physical addresses, nobody else can be reading the old ones.


Go deeper

Sources: PostgreSQL Docs: Routine Vacuuming — its transaction-ID-wraparound section explains the other reason vacuum is non-optional, one this exercise doesn't reach (a cluster that stops accepting writes entirely) · Database Page Layout for what heap_page_items is showing you


Cleanup

docker stop pg-mvcc