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 then find out which kind of idle transaction stops vacuum cleaning up anything at all — it is not the kind everyone names.
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.
PostgreSQL 16 documentation, §13.1 “Introduction” (Chapter 13, Concurrency Control) for what MVCC is:
This means that each SQL statement sees a snapshot of data (a database version) as it was some time ago, regardless of the current state of the underlying data.
and §25.1.2 “Recovering Disk Space” (Chapter 25, Routine Database Maintenance Tasks) for the two claims Parts 3–4 test:
In PostgreSQL, anUPDATEorDELETEof a row does not immediately remove the old version of the row. This approach is necessary to gain the benefits of multiversion concurrency control (MVCC, see Chapter 13): the row version must not be deleted while it is still potentially visible to other transactions.
The standard form ofVACUUMremoves dead row versions in tables and indexes and marks the space available for future reuse. However, it will not return the space to the operating system, except in the special case where one or more pages at the end of a table become entirely free and an exclusive table lock can be easily obtained. In contrast,VACUUM FULLactively compacts tables by writing a complete new version of the table file with no dead space.
The load-bearing phrase is “still potentially visible to other transactions.” Part 4 is entirely about pinning down what potentially visible means in practice — and the docs' own §13.2.1/§13.2.2 answer it, because Read Committed “starts each command with a new snapshot” while Repeatable Read “sees a snapshot as of the start of the first non-transaction-control statement in the transaction.” Those two sentences are the difference between a vacuum that reclaims everything and one that reclaims nothing.
Parts 1–3 are self-contained. Part 4 is where the exercise's surprise lives, and it leans on three things:
xmin committed before your snapshot and its xmax hasn't. Used everywhere, but decisive in Part 1 when you read raw tuples off the page. → PostgreSQL docs §13.1, Introductionpg_stat_activity.backend_xmin exposes each backend's contribution; backend_xid exposes the other contribution people forget (Part 7). → PostgreSQL docs §74.1, Transactions and IdentifiersHelpful but not load-bearing: heap page layout (ctid, line pointers, lp_off) for reading Part 1's raw page dump — §73.6, Database Page Layout — and HOT updates, which explain Part 3's oddly small “tuples removed” count.
6.12.76-linuxkit, 16 GiB allocated to the VMpostgres:16 — PostgreSQL 16.14 (Debian 16.14-1.pgdg13+1), digest sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20xmin, xmax, removable cutoff, backend_xmin) depend on how much the cluster has done — they will not match yours, and they need not even be consecutive, since background activity consumes ids too. So are exact dead-tuple counts, which shift with opportunistic HOT pruning. The relationships between them are the lesson and those reproduce exactly: sizes, ctid movement, 0 removed vs N removed, and removable cutoff matching the blocking backend's backend_xmin.
Three system columns are the whole story. Every table has them; they're just hidden from SELECT *:
| Column | What 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. |
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:
| Mechanism | What it actually does | Reach 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. |
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.
Two terminals, side by side. Everything below assumes you have Docker.
docker run --name pg-mvcc --rm -e POSTGRES_PASSWORD=postgres -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);
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.
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.
SELECT ctid, xmin, xmax, * FROM items;
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.
UPDATE items SET qty = qty + 1 WHERE id = 1;
ctid stay (0,1)?SELECT ctid, xmin, xmax, * FROM items;
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. (733 → 745 rather than 733 → 734: background activity consumed the ids in between. Transaction ids are cluster-wide, not per-table.)
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));
Four tuples for one logical row. Read the t_xmax/t_ctid columns as a linked list:
Version 1 was killed by txn 745 and points forward to (0,2); (0,2) was killed by 746 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.
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 mostly stops applying.
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.
INSERT INTO items SELECT g, 'item-' || g, g FROM generate_series(2, 100000) g;
SELECT pg_size_pretty(pg_relation_size('items'));
5096 kB for 100,000 rows.
UPDATE items SET qty = qty + 1; -- check size, then run it again
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.
SELECT n_live_tup, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'items';
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; that exact shortfall is HOT-dependent and will vary.
What this part tests: the distinction flagged at the end of the mental model, and §25.1.2's “it will not return the space to the operating system.” VACUUM frees the dead space — the question is who gets it back, your table or your filesystem.
VACUUM (VERBOSE) items;
pg_relation_size drop back toward 5096 kB?Trimmed to the table's own output; vacuum also reports on the table's TOAST relation and on per-run I/O and WAL counters.
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. (The 27 is opportunistic and run-variable; what reproduces is that it is tiny next to the ~200,000 line pointers.)
UPDATE items SET qty = qty + 1;
UPDATE push it to 20 MB and 25 MB?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.
What this part tests: vacuum's one hard constraint, from §25.1.2 — a dead version “must not be deleted while it is still potentially visible to other transactions.” Everyone knows the folk version of this rule: a forgotten BEGIN in another session blocks vacuum. This part needs both terminals, and it is where that folk version turns out to be wrong.
Session A VACUUM items; for a clean slate. Size is 15 MB. Then:
BEGIN;
SELECT count(*) FROM items;
Don't commit. Leave it sitting there. This is the textbook idle-in-transaction session — an app that forgot to close one, or a developer who typed BEGIN and went to lunch. Note that no isolation level was specified, so it is Read Committed, the Postgres default.
UPDATE items SET qty = qty + 1;
VACUUM (VERBOSE) items;
VACUUM clean them up?All 100,000 removed, and 0 are dead but not yet removable. The idle transaction did not block vacuum at all.
SELECT pid, state, xact_start, backend_xmin, left(query, 40) AS query
FROM pg_stat_activity WHERE datname = 'postgres';
B (pid 85) is genuinely idle in transaction with an xact_start minutes in the past — and its backend_xmin is null. It is holding no snapshot.
backend_xmin above is A itself, running this very query.
COMMIT;
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM items;
Session A
UPDATE items SET qty = qty + 1;
VACUUM (VERBOSE) items;
VACUUM still clean up?0 removed … 100000 are dead but not yet removable. Vacuum ran, did its scan, and reclaimed nothing. Under Repeatable Read the snapshot is taken once, at the first statement, and held until commit (§13.2.2) — so every one of those old versions is still potentially visible to B, and vacuum is not allowed to touch them. Note also index scans: 0: with nothing removable there is no point walking the index at all.
SELECT pid, state, xact_start, backend_xmin, left(query, 40) AS query
FROM pg_stat_activity WHERE backend_xmin IS NOT NULL;
Same session, same idle state — but now backend_xmin = 755, and vacuum reported removable cutoff: 755. Identical. That's the whole causal chain in two numbers. (Row 86 is A itself; its backend_xmin is one higher and irrelevant. Filtering out pid = pg_backend_pid() makes this a cleaner production query.)
UPDATE items SET qty = qty + 1; -- twice
VACUUM items;
SELECT pg_size_pretty(pg_relation_size('items'));
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.
Session B COMMIT;
VACUUM (VERBOSE) items;
All 300,000 collected the instant the blocker went away. Vacuum was never broken. Size is still 20 MB, per Part 3.
What this part tests: the one mechanism that actually returns disk. Per §25.1.2 it does so by “writing a complete new version of the table file with no dead space,” which is why it's the option you reach for last.
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;
VACUUM FULL items;
ctid?before VACUUM FULL | after | |
|---|---|---|
| heap | 20 MB | 5096 kB |
| primary key index | 6600 kB | 2208 kB |
| row 1 lives at | (1910,132) | (0,1) |
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.
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.
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;
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 640 heap blocks just to check visibility, even though id is in the index and the query needs nothing else.
VACUUM items; -- plain, not FULL
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.
Part 4 concluded that a Read Committed reader pins nothing. That is true, and it is also a trap: backend_xmin is not the only way a session holds back the horizon.
Session A CREATE TABLE scratch (x int);
SELECT, no snapshot held, and it never touches items:
BEGIN;
INSERT INTO scratch VALUES (1);
Session A
UPDATE items SET qty = qty + 1;
VACUUM (VERBOSE) items;
items?Blocked just as hard. One INSERT into an unrelated table was enough.
SELECT pid, state, backend_xmin, backend_xid, left(query, 32) AS query
FROM pg_stat_activity WHERE datname = 'postgres';
B's backend_xmin is still null — but the moment it wrote, it was assigned a real transaction id, backend_xid = 761, and a running transaction id is itself below the horizon. Vacuum's removable cutoff: 761 is exactly that id.
backend_xmin (it is holding a snapshot) or a backend_xid (it has written and not yet ended). Query both columns, sort by the smaller of the two, look at xact_start, and go ask that session what it's doing. A query that checks only backend_xmin — the obvious one — would have reported nothing wrong here.
Session B ROLLBACK; Session A DROP TABLE scratch;
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.
Then the part worth being wrong about: a textbook idle BEGIN; SELECT … in another terminal that does not block vacuum at all (100000 removed, backend_xmin null), and the same session with ISOLATION LEVEL REPEATABLE READ added that reduces the identical VACUUM to 0 removed … 100000 are dead but not yet removable, with the table growing to 20 MB regardless. And a third case — a Read Committed transaction that merely inserted one row into an unrelated table — that blocks vacuum just as completely while showing a null backend_xmin. Finally VACUUM FULL returning the table to 5096 kB by rewriting the file, at the price of locking out every reader.
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. Vacuum computes one number — the xmin horizon, the oldest transaction id any backend might still care about — and refuses to remove anything a transaction at or after that id created or killed. Every backend contributes to that number in exactly two ways: the oldest snapshot it currently holds (backend_xmin) and its own transaction id if it has one (backend_xid).
REPEATABLE READ and the snapshot is taken once and held (§13.2.2), so backend_xmin pins the horizon and vacuum removes exactly zero. Insert one row into an unrelated table instead and backend_xid pins it just as hard. Same “idle in transaction”, three different outcomes, decided entirely by which of those two fields is populated.
The boundary condition is worth naming: the effect vanishes if the blocking transaction's snapshot is newer than the dead tuples. Vacuum does not care how long a transaction has been open, only how old its horizon is. A session that opened REPEATABLE READ after your updates blocks nothing, and a session opened before them blocks everything — which is why xact_start is a heuristic and backend_xmin is the answer.
And because VACUUM only marks space free within the file, the file itself never shrinks (§25.1.2's “it will not return the space to the operating system”). 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.
ALTER TABLE items SET (autovacuum_enabled = on)) and repeat Part 2. Watch last_autovacuum in pg_stat_user_tables and see how much bloat accumulates before the 20% threshold trips.SERIALIZABLE instead of REPEATABLE READ, then with a REPEATABLE READ transaction opened after the updates. Only the first blocks — the boundary condition named in “Why”, made visible.VACUUM and re-read the page asking for lp_flags this time:
CREATE TABLE hot_demo (id int PRIMARY KEY, qty int) WITH (autovacuum_enabled = off);
INSERT INTO hot_demo VALUES (1, 0);
UPDATE hot_demo SET qty = qty + 1 WHERE id = 1; -- three times
SELECT lp, lp_flags, t_ctid FROM heap_page_items(get_raw_page('hot_demo', 0));
VACUUM hot_demo;
SELECT lp, lp_flags, t_ctid FROM heap_page_items(get_raw_page('hot_demo', 0));
Before the vacuum all four line pointers are flag 1 (NORMAL) with a t_ctid. After it:
2 (REDIRECT), still occupying its slot; pointers 2 and 3 became 0 (UNUSED, fully reclaimed); 4 stays 1 (NORMAL). The redirect is the trick that makes HOT work: the index entry still points at slot 1, so it never had to be rewritten, and slot 1 now just forwards to the live version. Contrast with Part 2's full-table updates, which mostly weren't HOT — measured on items at the end of Part 6, this run showed n_tup_upd = 1050003 against n_tup_hot_upd = 1129, about 0.1% — which is exactly why the vacuum in Part 3 had to do an index pass.
VACUUM FULL while another session sits in a plain SELECT loop, and watch the reader block in pg_stat_activity — proof that ACCESS EXCLUSIVE isn't just about writers. Then try REINDEX TABLE CONCURRENTLY on the same table to see the online alternative.DELETE 90% of the table, VACUUM, and check the size. Then insert 50,000 fresh rows and check again. The free space map means the file shouldn't grow — you can watch space get recycled instead of allocated.items table triples because a full-table UPDATE on an indexed table can't use HOT (measured at the end of Part 6: 1129 HOT updates out of 1,050,003). ddia/ch08/update-bloat-mvcc runs the complementary experiment — nobody else connected, 100,000 updates to a one-row table, and the only thing that changes is whether the updated column is indexed. Predict the size gap before you look; it is 344×, one 8 kB page against 2752 kB, decided entirely by whether HOT pruning was allowed to fire. Between the two you have both halves of "why did my table bloat": the index that disabled pruning, and the transaction that pinned the horizon.SELECT to SELECT … FOR UPDATE and the concurrent writer blocks after all. Worth running against Part 4's mental model, since a session holding FOR UPDATE is a session that has written — so it pins the vacuum horizon via backend_xid as well as blocking the writer.Sources: PostgreSQL 16 Docs §13.1: Introduction (MVCC) · §25.1: 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) · §13.2: Transaction Isolation for the snapshot-lifetime rule Part 4 turns on · §73.6: Database Page Layout for what heap_page_items is showing you
The container was started with --rm, so stopping it removes the container, its writable layer, the items table and the extension in one go. Nothing is left on disk and no volume or network was created.
docker stop pg-mvcc