Internals · MVCCdead tupleupdatedeletehot updatevacuum

UPDATE, DELETE and Dead Tuples

Under MVCC an UPDATE is an insert plus a stamp and a DELETE is only a stamp; neither frees a byte, so every write leaves a dead version behind that some later process — VACUUM, autovacuum, InnoDB purge — has to find, remove from the page and every index, and hand back to the free space map before the table stops growing.

▶ InteractiveTry queriesInterview question
Progress

Why this exists

The mechanism as the answer to a problem — read this before the name.

  1. Problem

    MVCC promised that UPDATE writes a new version beside the old one and DELETE only marks. Then a table with a constant 800 rows, updated a thousand times a second, is one byte larger after every update. Where does it stop?

  2. Naive solution

    Remove the old version as soon as the updating transaction commits — the new version is now the truth.

  3. Why it breaks

    A report that started before the commit still needs the old version; removing it makes the report see a row disappear or read garbage. Also every index still points at the old tuple's address, and removing it leaves dangling index entries.

  4. Better idea

    An old version is reclaimable only when no snapshot can see it — when the oldest snapshot in the system started after the version's deleter committed. Compute that horizon, and let a separate pass remove everything below it, from the heap and from every index.

  5. Internal mechanism

    Dead tuples accumulate; a vacuum pass scans the table, collects dead tuple addresses below the horizon, removes the matching index entries, marks the heap slots free, and records each page's free space in a map that inserts consult. In-page pruning and HOT chains do the cheap part without a full pass.

  6. Trade-offs

    Space is reclaimed for reuse, not returned to the filesystem; the pass costs I/O against the workload; a single long-running snapshot can stall reclamation for a whole table; and update-heavy rows generate dead versions faster than any pass can remove them.

  7. Real database

    PostgreSQL: VACUUM and autovacuum, HOT updates, the free space map and visibility map, VACUUM FULL or pg_repack for the table rewrite. InnoDB: delete-marked records, purge threads and the history list length.

Choose your depth

The same mechanism at four altitudes. Start where you are; come back deeper.

Writes leave corpses

Under MVCC nothing is removed by the statement that logically removes it. An UPDATE adds a new row and marks the old; a DELETE marks. The marked rows are dead once no transaction can see them, but they still take space on the page and entries in every index. Something has to sweep them up, and until it does the table grows.

What UPDATE actually writes

PostgreSQL implementation

An UPDATE accounts SET balance = 80 WHERE id = 7 does four things. It takes the row lock by writing its xid into the old tuple's xmax (with the lock bits, as The Lock Manager describes). It builds the new tuple with xmin = its xid. It inserts that tuple — into the same page if the free space allows, otherwise into a page the free space map suggests. And it sets the old tuple's t_ctid to the new address and clears the lock-only bits so the xmax now means "superseded". The old tuple is untouched otherwise; the new one is a full copy of the row, changed columns and unchanged columns alike. A one-byte change to a 2 KB row writes 2 KB.

Then the indexes. Every index entry is (key → tuple address). The new tuple has a new address, so each index needs a new entry — including indexes on columns whose values did not change, because the address did. Each such insert may split an index page, and each leaves behind an entry pointing at the old version that will be dead once the horizon passes. This is the write amplification in Why Is This Query Slow? Indexes seen from below: an UPDATE costs one heap write plus one write per index, not because of the changed column but because of the changed address.

Before and after an UPDATE (page 91, one index on id, one on balance)
before
  heap page 91   slot 3: xmin=10 xmax=0   ctid=(91,3)  id=7 balance=100
  idx accounts_pkey     [7]   -> (91,3)
  idx accounts_bal_idx  [100] -> (91,3)

after UPDATE by xid 15   (balance is indexed -> not HOT)
  heap page 91   slot 3: xmin=10 xmax=15  ctid=(91,8)  id=7 balance=100   <- dead once horizon > 15
                 slot 8: xmin=15 xmax=0   ctid=(91,8)  id=7 balance=80
  idx accounts_pkey     [7]   -> (91,3)     [7]  -> (91,8)               <- two entries, one dead
  idx accounts_bal_idx  [100] -> (91,3)     [80] -> (91,8)

HOT: skipping the index when you can

PostgreSQL implementation

If the update changes no indexed column and the new version fits on the same page, PostgreSQL performs a heap-only tuple update. The new tuple is written on the page, the old one's t_ctid points at it as usual, and *no index entry is written*: the indexes keep pointing at the old slot, marked as the head of a HOT chain. A lookup arrives at the old slot, sees the chain, and follows t_ctid within the page to the visible version. Since everything stays on one page, following the chain costs no extra I/O.

HOT chains have a second gift: pruning. When a later access finds the page more than a certain fraction full, it can, without VACUUM and without touching indexes, collapse dead chain members — the line pointer that the index points at is turned into a *redirect* to the live version and the dead tuples' space is freed in-page. A hot row that is updated a thousand times a second on a page with spare room can cycle through versions indefinitely at almost no cost. That is why fillfactor = 70 on an update-heavy table is a standard tuning: leaving 30% free per page keeps updates HOT.

A HOT chain and its pruned form
HOT update (balance not indexed this time; only accounts_pkey on id)
  slot 3: xmin=10 xmax=15  ctid=(91,8)   HEAP_HOT_UPDATED       <- index points here
  slot 8: xmin=15 xmax=0   ctid=(91,8)   HEAP_ONLY_TUPLE        <- no index entry
  idx accounts_pkey  [7] -> (91,3)       (unchanged)

after pruning (slot 3 dead, horizon passed)
  slot 3: REDIRECT -> 8                  (line pointer only, no tuple bytes)
  slot 8: xmin=15 xmax=0   id=7 balance=80
  idx accounts_pkey  [7] -> (91,3) -> redirect -> (91,8)   still valid, never rewritten

What DELETE actually writes

A DELETE writes the deleting transaction's id into the tuple's xmax (or, on undo-based engines, sets a delete-mark and writes an undo record). The tuple stays. It must: a transaction whose snapshot predates the delete is entitled to see it, and after a ROLLBACK the row must simply reappear, which it does because an aborted xmax reads as "not deleted". Index entries stay too; there is nothing in an index to say "deleted" — a lookup finds the entry, fetches the tuple, applies the visibility rule and discards it.

So the row becomes *invisible* at commit but *reclaimable* only later, when the oldest active snapshot postdates the deleting transaction. That gap — between invisible and reclaimable — is where dead tuples live, and its length is the age of the oldest snapshot in the system.

Dead tuples and the horizon

A dead tuple is a version no current or future snapshot can see: its xmax committed before the oldest snapshot still open started, or its xmin aborted. The threshold is the system's oldest xmin horizon: the minimum over every active snapshot's xmin, every replication slot's retained xmin, and every prepared transaction. One session that ran BEGIN four hours ago and is now idle sets the horizon four hours in the past; every version superseded since then, in every table, is invisible to everyone yet unreclaimable, because that one snapshot might still ask for it. The practical lesson MVCC: Multi-Version Concurrency Control calls this "idle in transaction"; here is the number it pins.

Dead tuples cost on every read. A sequential scan reads pages that are mostly dead versions, applies the visibility rule to each, and keeps a fraction. An index scan follows entries to tuples it then discards. The table's size in pages — what the planner's cost estimates use — no longer reflects its live rows. Performance Internals: Why the Slow Node Is Slow sees this as "the same query, slower every week, with no change in row count".

The horizon, and what it pins
active snapshots:  session 4  xmin 1,204,118   (started 4 h ago, idle in transaction)
                   session 9  xmin 1,391,020
                   session 2  xmin 1,391,077
replication slot "analytics"  xmin 1,388,500
oldest xmin horizon = 1,204,118

accounts: 812 live tuples, 1,930,447 dead tuples, 25,600 pages (200 MB) for 80 KB of live data
  reclaimable now:   versions with xmax < 1,204,118  ->  0.4% of the dead ones
  pinned by session 4: the rest

VACUUM, autovacuum and the free space map

PostgreSQL implementation

VACUUM is the pass that turns dead into free. It scans the heap — skipping pages the visibility map marks all-visible — and collects the addresses of dead tuples below the horizon into a work array. When the array fills or the scan ends it walks every index and bulk-deletes entries pointing at those addresses; then it returns to the heap, marks the collected slots unused, and defragments each page so the free bytes are contiguous. It records each page's free space in the free space map (FSM), a small side file with one byte per heap page arranged as a tree of maxima, which INSERT and non-HOT UPDATE consult to find a page with room. Finally it sets all-visible bits in the visibility map for pages with no dead tuples (which also lets index-only scans skip heap fetches) and, if the last pages of the file are entirely empty, truncates them.

Autovacuum runs this automatically: a launcher wakes every autovacuum_naptime (1 min), and a worker vacuums any table whose dead-tuple count from the statistics collector exceeds autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × reltuples — 50 rows plus 20% of the table by default, which is far too lazy for a small hot table and roughly right for a large one. Workers throttle themselves with autovacuum_vacuum_cost_delay so as not to saturate I/O, which on a busy system can mean they never catch up. Per-table settings (ALTER TABLE … SET (autovacuum_vacuum_scale_factor = 0.01)) are the standard fix for hot tables.

VACUUM in phases
1vacuum(table):
2 horizon = oldest_xmin() # snapshots, slots, prepared txns
3 dead = []
4 for page in table.pages:
5 if visibility_map.all_visible(page): continue
6 for tup in page.tuples:
7 if tup.xmax committed and tup.xmax < horizon or tup.xmin aborted:
8 dead.append(tup.tid)
9 if len(dead) == LIMIT: flush() # phases 2+3 may repeat
10 flush():
11 for idx in table.indexes: idx.bulk_delete(dead) # every index, every dead tid
12 for tid in dead: table.page(tid).mark_unused(tid) # then the heap
13 for page in touched: fsm.set(page, page.free_bytes)
14 truncate_trailing_empty_pages(); update_stats()

Bloat and storage growth

Bloat is the space a table or index occupies beyond what its live data needs. It comes from dead tuples VACUUM has not yet removed, from free space VACUUM has reclaimed but inserts have not refilled, and — in indexes — from pages that emptied but cannot be merged. Ordinary VACUUM never returns space to the filesystem except by truncating empty trailing pages, so a table that once held ten million rows and now holds ten thousand keeps its ten-million-row file until someone rewrites it: VACUUM FULL (which takes an exclusive lock and rewrites into a new file) or pg_repack (which does the same online). Indexes bloat separately and are fixed by REINDEX CONCURRENTLY.

The growth pattern to recognise: row count flat, table size climbing, n_dead_tup climbing, last_autovacuum recent but ineffective — that is a pinned horizon, and the cure is finding the session or slot holding it, not tuning autovacuum. Row count flat, size climbing, last_autovacuum old — that is autovacuum unable to keep up, and the cure is the scale factor and cost delay. The challenge on table bloat walks one of these.

Purge threads and undo growth

MySQL / InnoDB implementation

InnoDB reaches the same place by a different road. DELETE sets a delete-mark bit on the record — in the clustered index and in each secondary index — and writes an undo record so the delete can be rolled back and so older read views can still see the row. UPDATE of a secondary-index column delete-marks the old index entry and inserts a new one; UPDATE of a non-indexed column changes the row in place with the old image in undo. Nothing is physically removed by the statement.

Purge is InnoDB's vacuum: background threads walk the history list — undo records of committed transactions, oldest first — and for each one whose transaction is older than every open read view, physically remove the delete-marked records and free the undo. The measure of pending work is History list length in SHOW ENGINE INNODB STATUS; a long-running transaction with an open read view stops purge at its position, the list grows into the millions, the undo tablespace grows on disk, and every read of a hot row has to walk a longer undo chain. The symptoms — disk growing, reads slowing, one old transaction — are the PostgreSQL bloat story with the garbage in undo space instead of in the table.

Key points

  • UPDATE = new full tuple + xmax stamp on the old + t_ctid link, plus a new entry in every index unless the update is HOT.
  • DELETE = xmax stamp; the row is invisible at commit but reclaimable only once the oldest snapshot postdates it.
  • The oldest xmin horizon (snapshots, replication slots, prepared transactions) bounds what VACUUM may remove; one idle transaction pins every table.
  • VACUUM collects dead tids, bulk-deletes them from every index, frees heap slots, updates the free space and visibility maps; it reuses space and rarely returns it.
  • Bloat is size beyond live data; the fix depends on whether the horizon is pinned or autovacuum is behind. InnoDB has the same problem as history list length and undo growth.

UPDATE, DELETE and dead tuples

UPDATE, DELETE and dead tuples
Under MVCC neither UPDATE nor DELETE removes anything. They stamp xmax and, for UPDATE, append a new version. The page fills with dead tuples until VACUUM proves nobody can still see them.
Heap page 17 — 8 line pointers
page header 24 B · pd_lower/pd_upper · 8 × 4 B line pointerslp 1r1 = 100xmin 100xmax ∅livelp 2r2 = 200xmin 100xmax ∅livelp 3r3 = 300xmin 100xmax ∅livelp 4r4 = 400xmin 100xmax ∅livelp 5freelp 6freelp 7freelp 8free
Page fill4,056 B
Dead tuples vs autovacuum threshold (2)0
Live tuples
4
Dead tuples
0
Reclaimable now
0
xmin horizon
101 (no old snapshot)
Free space map
0 B
Run a few UPDATEs and DELETEs, then open a long-running reader before you VACUUM.
PostgreSQL implementation
VACUUM reclaims a dead tuple only when its xmax is older than every live snapshot\'s xmin (the horizon). Reclaimed slots are reusable and their bytes go into the free space map of the page; the file never shrinks unless VACUUM FULL rewrites it. autovacuum fires at 50 + 0.2 × n_live_tup dead tuples per table — scaled here to 2 for an 8-slot page.
MySQL / InnoDB implementation
No dead tuples in the page. InnoDB overwrites the row in place and pushes the old image into the undo log. A background purge thread deletes undo records once no read view needs them — the same horizon problem, in a different structure. A long-running consistent read shows up as a growing History list length instead of table bloat.
Educational simulation — 1 KB tuples so eight fill an 8 KB page; every statement is its own autocommit transaction; HOT updates and index pointers are omitted.

Try it in the playground

When to use — and when not

Use it when
  • Deferred reclamation with a horizon fits any multi-version engine: it is the only way to keep versions that old snapshots need while still reusing space.
  • HOT-style in-page chains fit update-heavy tables with few indexed columns changing; leave fill factor room for them.
Avoid it when
  • Heap-stored versions do not fit tables whose every update changes an indexed column on a table with many indexes — each update writes every index twice over its life.
  • Default autovacuum thresholds do not fit small hot tables; set per-table scale factors.

Failure modes

  • A single idle-in-transaction session pinning the horizon for hours; n_dead_tup climbing while autovacuum runs and reclaims nothing.
  • A replication slot whose consumer died, holding the horizon (and WAL) indefinitely.
  • Autovacuum starved by cost delay on a table with 20% scale factor and a billion rows: 200 million dead tuples before it starts.
  • Expecting VACUUM to shrink the file; it will not without VACUUM FULL or pg_repack.
  • On InnoDB, a growing history list and undo tablespace behind one long report.

Where you meet this

Back up to the practical layer, and across to the rest of Engineer Atlas.