Lesson 0002 · Storage & MVCC

Dead tuples, VACUUM & HOT updates

Lesson 0001 left dead tuples piling up in the heap. This lesson follows them: who removes them, when autovacuum decides to run, and the one trick — HOT — that stops an UPDATE from touching every index.

~10 minWarm-up recall + quiz + InnoDB contrastCheat sheet: here

Warm-up: recall lesson 0001 first

Before new material, pull the keystone back from memory — retrieval is what makes it stick. Answer from your head, not by re-reading 0001.

In Postgres, an UPDATE to a row physically results in:

Never in place: a new tuple is written and the old one is marked expired, left in the heap as a dead tuple. Sending the old image to undo is InnoDB's model, not Postgres's.

Complete the chain: never update in place →

The whole course spine: old versions linger as dead tuples, occupying space (bloat) until VACUUM reclaims it. That's exactly what this lesson unpacks.

Good. You know dead tuples accumulate and that VACUUM reclaims them. Two questions were left open: when does that cleanup actually fire, and can we make an UPDATE cheaper in the first place? Both answers matter because — remember from 0001 — every Postgres index is secondary, so by default a new row version means a new entry in every index on the table.

What VACUUM actually does with the space

Here is the fact that surprises people: plain VACUUM does not shrink your table on disk. It marks dead-tuple space reusable inside the table, but does not hand it back to the operating system. PostgreSQL Docs: "The standard form of VACUUM removes dead row versions… and marks the space available for future reuse. However, it will not return the space to the operating system…"

So after a bloat episode, a vacuumed table stays large on disk but stops growing — new rows fill the freed slots. To physically shrink it you need VACUUM FULL, which rewrites the whole table with no dead space — but takes an ACCESS EXCLUSIVE lock, blocking all reads and writes for the duration. PostgreSQL Docs: "VACUUM FULL actively compacts tables by writing a complete new version of the table file with no dead space… requires an ACCESS EXCLUSIVE lock."

The footgun

Reaching for VACUUM FULL to "fix bloat" on a live table locks it completely — an outage. Routine bloat is meant to be held in check by autovacuum (below), so the table reaches a steady state and reuses its own freed space. VACUUM FULL is a last resort for a table that already bloated badly, run in a maintenance window.

Autovacuum: the janitor's trigger

You rarely run VACUUM by hand. Autovacuum automates it. PostgreSQL Docs: "PostgreSQL has an optional but highly recommended feature called autovacuum, whose purpose is to automate the execution of VACUUM and ANALYZE commands." A table is vacuumed once its number of dead tuples crosses a threshold that is proportional to the table's size:

vacuum threshold = autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × reltuples = 50 (default) + 0.2 (default) × row count

— formula and defaults from the docs: base threshold default 50 tuples, scale factor default 0.2 (20% of table size). (Recent versions also cap it with a Minimum(...) max-threshold; the proportional term is the mental model that matters.)

Read the shape, not the arithmetic: a table is vacuumed after roughly 20% of its rows have become dead. Two consequences fall straight out:

Contrast — InnoDB hides this

In InnoDB the equivalent cleanup (purging old versions from the undo log) is a continuous background job you never configure per table. Postgres exposes the janitor: it has a trigger you can see (n_dead_tup) and knobs you can turn. More responsibility, more control — the recurring theme of this course.

HOT: making an UPDATE skip the indexes

Now the payoff. Normally an UPDATE writes a new tuple and, because every index is secondary and points at a physical tuple, must add a new entry to every index on the table — write amplification that also creates more index bloat. Heap-Only Tuples (HOT) is the optimization that avoids it entirely, when two conditions hold:

  1. the update doesn't change any indexed column, and PostgreSQL Docs: "The update does not modify any columns referenced by the table's indexes…"
  2. there is room on the same heap page for the new version. PostgreSQL Docs: "There is sufficient free space on the page containing the old row for the updated row."

When both hold, the new version is chained to the old one within the page and no new index entries are created — the existing index entry still points at the page, and a redirect leads to the live version. PostgreSQL Docs: "New index entries are not needed to represent updated rows…"

NON-HOT update (changed an indexed col, or page full) new tuple on maybe-another page + new entry in EVERY index ← write amplification HOT update (no indexed col changed, room on the page) new tuple on the SAME page, chained to the old ← heap only indexes untouched — the old entry's redirect finds the live version

You raise HOT's hit rate by leaving free space on each page — lower the table's fillfactor below 100 so pages start with room for future in-page versions. PostgreSQL Docs: "You can increase the likelihood of sufficient page space for HOT updates by decreasing a table's fillfactor." There's a second gift: HOT chains can be pruned during ordinary reads, reclaiming the intermediate dead versions without waiting for a full VACUUM.

Postgres vs InnoDB — the maintenance model

QuestionInnoDBPostgreSQL
Who removes old versions?Background purge (automatic)Autovacuum (tunable, per-table)
When is it triggered?Continuous, not per-table~20% of rows dead (scale factor)
Does cleanup shrink the file?N/A (in-place)Plain VACUUM: no. VACUUM FULL: yes, with a lock
UPDATE that changes no indexed colIn place; indexes untouched anywayHOT: heap-only, indexes untouched
How to help the cheap pathLower fillfactor to leave page room

Check yourself

From memory — the effortful recall is the point. Two items reach back to earlier ideas on purpose.

After plain VACUUM on a bloated table, the file on disk usually:

Plain VACUUM marks space reusable inside the table but doesn't return it to the OS, so the file stays large and new rows refill it. Only VACUUM FULL rewrites and shrinks — under an exclusive lock.

Roughly how many rows must go dead before default autovacuum stirs?

Threshold = 50 + 0.2 × rows, so the scale factor dominates: ~20% of the table. The fixed 50 only matters for tiny tables; big tables are governed by the proportion.

On a huge, hot table the usual autovacuum tuning move is to:

A big table at 20% dead is a lot of bloat, so you lower autovacuum_vacuum_scale_factor (often per-table) to vacuum sooner. Raising it would let even more bloat accumulate.

A HOT update is possible only when the update:

Both must hold: no indexed column changes AND there's room on the same page. Then the new version is heap-only and no index entries are added.

Why does a normal (non-HOT) UPDATE add an entry to every index? (recall 0001)

Every Postgres index is secondary and points at a physical tuple's location. A new tuple therefore needs a new pointer in each index — unless HOT keeps it on the page and reuses the old pointer.
Optional — when you have an instance

A lab to run later. It shows autovacuum's counters and HOT firing:

CREATE TABLE t (id int PRIMARY KEY, note text, val int);
CREATE INDEX ON t (note);                 -- an index on note, but NOT on val
INSERT INTO t SELECT g, 'x', 0 FROM generate_series(1,1000) g;

UPDATE t SET val = val + 1;               -- changes val only (not indexed) → HOT-eligible
UPDATE t SET note = note || 'y';          -- changes note (indexed) → NOT HOT

SELECT n_dead_tup, n_tup_upd, n_tup_hot_upd
FROM pg_stat_user_tables WHERE relname='t';   -- compare hot vs total updates

n_tup_hot_upd should rise after the first UPDATE but not the second. Try re-creating the table with WITH (fillfactor=70) and watch the HOT ratio improve. Bring the numbers to your teacher.

Primary source — read this next

PostgreSQL Docs — 24.1 Routine Vacuuming for the VACUUM/autovacuum model and the threshold formula, then the short 66.7 Heap-Only Tuples (HOT) page. For the mechanism drawn out tuple-by-tuple, Suzuki's "Internals of PostgreSQL," ch. 6 (VACUUM) and ch. 7 (HOT & index-only scans).