studyDDIAChapter 8 · Transactions

One Row, Updated 100,000 Times — 8 kB or 2.75 MB?

A one-row table, updated 100,000 times. In Postgres an UPDATE is internally a delete plus an insert, so the row's ctid moves every time — yet the heap's size depends entirely on one thing: whether v is indexed. Unindexed, HOT pruning keeps it at a single 8 kB page; indexed, the same updates balloon it to 2752 kB of heap plus 2208 kB of index with ~99,421 dead tuples. Same workload, 344× apart. One VACUUM reclaims the dead space.


Concept

You have a table with one row. You run UPDATE t SET v = v+1 WHERE id = 1 one hundred thousand times. In Postgres an UPDATE is internally a delete plus an insert — you can watch the row's physical address (ctid) move on every single update, proof that each one wrote a new version and abandoned the old. So does the table bloat a hundred-thousand-fold? That depends entirely on one thing: whether v is indexed. Leave v unindexed and the heap stays a single 8 kB page — HOT pruning reclaims each dead version in place as it goes, so the table never grows. Add an index on v and those same 100,000 updates can no longer prune: the heap balloons to 2752 kB, the index to 2208 kB, and ~99,421 dead tuples pile up until VACUUM. Identical workload, one live row either way — 344× apart in size, decided by a single index.

Provenance

Designing Data-Intensive Applications, 2nd ed. (Kleppmann & Riccomini, 2025), Chapter 8 — Transactions, §"Multiversion concurrency control":

An update is internally translated into a delete and an insert.

The book introduces MVCC to explain how a transaction sees a consistent snapshot while others write: instead of overwriting a row in place, the database keeps multiple versions and lets each transaction read the version its snapshot should see. This exercise makes that literal — you watch the versions get created (the moving ctid), watch them pile up when Postgres can't prune them, and watch the garbage collector (VACUUM) clean them up.

Prerequisites

The surprise is about what an in-place UPDATE really does to storage under MVCC — and why two tables under the identical workload end up 344× apart. These help; each line notes where it shows up.

  1. MVCC and row versions — a database keeps several versions of a row so concurrent transactions can each read the one their snapshot should see, rather than blocking or overwriting. It shows up as every UPDATE creating a new version instead of mutating the old one.
  2. Heap tuples and ctid — a Postgres table is a heap of pages; each row version (a tuple) lives at a physical address ctid = (page, slot). It shows up as the ctid of id=1 changing on almost every update.
  3. Dead tuples and bloat — once no snapshot can see an old version it is dead, but its bytes stay in the heap until reclaimed; dead tuples piling up is bloat. It shows up as n_dead_tup and pg_relation_size on the indexed table.
  4. VACUUM — the maintenance pass that finds dead tuples no snapshot needs and marks their space reusable (it does not usually return it to the OS). It shows up as n_dead_tup dropping to 0 while the file size stays put.
  5. HOT (Heap-Only Tuple) updates — an optimization: when an update changes no indexed column and the new version fits on the same page, Postgres skips the index and can prune the dead version in place, without a VACUUM. It is the whole reason the unindexed table (t) stays flat while the indexed one (t2) bloats to ~100k dead tuples.
Where to learn the prerequisites MVCC & row versions (#1): DDIA §"Multiversion concurrency control"; the PostgreSQL manual, "Concurrency Control — Introduction / MVCC." Heap, tuples, ctid, dead tuples, bloat (#2–#3): the PostgreSQL manual, "Database Physical Storage"; the pgstattuple and pg_stat_all_tables reference pages (for n_dead_tup). VACUUM & HOT (#4–#5): the PostgreSQL manual, "Routine Vacuuming," and the README.HOT design note in the source tree (or any "Heap-Only Tuples" write-up). If only one is new, make it #5 — HOT is the difference between the two tables, and understanding it is what turns "an update is a delete and an insert" from a scary-sounding claim into a precise, bounded cost.
Environment these numbers came from Real measurements from one capture run. Exact dead-tuple counts and transaction ids vary run-to-run (HOT pruning is opportunistic and the server's txid counter is shared); the shapes reproduce solidly — the unindexed table stays ~1 page (8192 B), the indexed table lands at ~2.75 MB with ~97k–99k dead tuples. PostgreSQL 16.14 (Debian, aarch64) in Docker on macOS 26.5.2 (Darwin 25.5.0), arm64; captured via psycopg[binary] on Python 3.12 (uv); autovacuum disabled on the tables so any version pile-up is observable; sizes are real measurements. Two one-row tables, t(id, v) with v unindexed (HOT-eligible) and t2(id, v) with an index on v (so updates are non-HOT). Each row updated 100,000 times.

Mental model

Every UPDATE in Postgres is a new tuple, old one marked dead — never an in-place edit. The reason is MVCC: a concurrent transaction reading an older snapshot may still need the old version, so the database cannot destroy it mid-flight. It writes the new version alongside, stamps the old one with the updating transaction's id (xmax), and moves on. The old version becomes dead once no live snapshot can see it — and dead versions either get reclaimed in place or accumulate as bloat, depending on one optimization:

Both cases still write 100,000 physical versions, so both still move the row's ctid — HOT only changes whether the old versions can be cleaned up as you go. The whole capture is one script, code/update_bloat_mvcc.py; a reader can type the same statements by hand in psql.

Setup

Connect to the shared Postgres 16 container and create your own database:

psql -h localhost -p 5432 -U postgres -d study -c "CREATE DATABASE ex_ch08_bloat"
psql -h localhost -p 5432 -U postgres -d ex_ch08_bloat

Then, in that session:

CREATE EXTENSION IF NOT EXISTS pgstattuple;
CREATE TABLE t (id int PRIMARY KEY, v int);
ALTER TABLE t SET (autovacuum_enabled = false);   -- so any pile-up is visible
INSERT INTO t VALUES (1, 0);

Or run the whole capture (drops + recreates ex_ch08_bloat, both scenarios):

uv run --python 3.12 --with "psycopg[binary]" python3 code/update_bloat_mvcc.py

Do not read the output yet — make each prediction first.


Step 1 · does an in-place UPDATE move the row?

Predict. You update the same row six times: UPDATE t SET v=v+1 WHERE id=1. The row's logical identity (id=1) never changes. Will its physical address ctid = (page, slot) stay put, or move?

Do. After each of six updates, read SELECT ctid, xmin, xmax, v FROM t WHERE id=1.

Does the ctid stay put or move?
[1] Initial state of t (one live row): ctid of row id=1 = (0,1) before any updates: live tuples (n_live_tup) = 1 dead tuples (n_dead_tup) = 0 heap size (pg_relation_size) = 8192 bytes (8192 bytes) pgstattuple: tuple_count=1 dead_tuple_count=0 dead%=0.0 free%=99.22 [2] ctid of t's row id=1 over 6 successive UPDATE t SET v=v+1 WHERE id=1: after update 0: ctid = (0,1) after update 1: ctid = (0,2) <- moved after update 2: ctid = (0,3) <- moved after update 3: ctid = (0,4) <- moved after update 4: ctid = (0,5) <- moved after update 5: ctid = (0,6) <- moved after update 6: ctid = (0,7) <- moved live row now: ctid=(0,7) xmin=1401348 xmax=0 v=6 (xmin = txid that created this version; each UPDATE writes a new one)

The ctid moves every single time(0,1) → (0,2) → … → (0,7). There is no in-place edit: each update wrote a new tuple at the next slot on the page and left the old one behind. xmin=1401348 is the transaction that created the current version; the previous six versions sit at slots 1–6, now dead. The "delete plus insert" is real and it happens every time.

Step 2 · 100,000 updates, no index on v (the surprise)

Predict. Run UPDATE t SET v=v+1 WHERE id=1 100,000 times total. Every one is a delete-plus-insert that moves the ctid, as Step 1 showed. So how big does the heap get — roughly 100,000 versions' worth of bloat?

How big does the heap get after 100,000 updates?
[3] Scenario A -- 99,994 more updates of t (100,000 total; v unindexed, HOT-eligible)... done. v = 100000; ctid of row id=1 = (0,124) (was (0,1)) t after 100k HOT-eligible updates: live tuples (n_live_tup) = 1 dead tuples (n_dead_tup) = 158 heap size (pg_relation_size) = 8192 bytes (8192 bytes) pgstattuple: tuple_count=1 dead_tuple_count=123 dead%=48.05 free%=41.26

Still 8192 bytes — one page. It did not bloat at all. After 100,000 delete-plus-inserts the heap is the exact same size it started, and the row's ctid never left page 0 (it ended at (0,124), a slot on the first page). Because v isn't indexed, every update is a HOT update, and Postgres prunes the dead versions within the page as it reuses it — the same 8 kB gets recycled 100,000 times. n_dead_tup reads 158 only because that's how many versions had accumulated since the last opportunistic prune; the count never runs away. The delete-plus-insert absolutely happened 100,000 times (the ctid kept moving, v reached 100000) — HOT just cleaned up in place so nothing piled up.

Step 3 · 100,000 updates, with an index on v

Predict. Same 100,000 updates, but now on t2 where v is indexed — so each update must add an index entry and can't be a HOT update. Same one live row. How big does the heap (and index) get this time?

With v indexed, how big does it get now?
[4] Scenario B -- 100,000 updates of t2 (v INDEXED, so each UPDATE is NON-HOT)... done. v = 100000; ctid of row id=1 = (343,188) t2 after 100k NON-HOT updates: live tuples (n_live_tup) = 1 dead tuples (n_dead_tup) = 99421 heap size (pg_relation_size) = 2818048 bytes (2752 kB) pgstattuple: tuple_count=1 dead_tuple_count=187 dead%=0.21 free%=0.05 index t2_v size = 2208 kB

99,421 dead tuples, and a heap that ballooned from 8 kB to 2752 kB — 344×. Same one row, same 100,000 updates — but indexing v defeated HOT. Each update had to write a new index entry pointing at the new version, so the old heap version could no longer be pruned in place; it just accumulated, and the row's ctid marched all the way to page 343 (343,188). The index bloated right alongside the heap, to 2208 kB. free% has collapsed to 0.05% — the pages are packed wall-to-wall with dead versions. (Ignore pgstattuple's own dead_tuple_count=187; the two accountings classify differently. The number autovacuum watches, and the one that matters operationally, is n_dead_tup ≈ 99,421.)

Step 4 · VACUUM, the garbage collector

Predict. Run VACUUM t; VACUUM t2;. What happens to n_dead_tup? And to pg_relation_size on the bloated table — does the 2752 kB file shrink back?

Do dead tuples clear, and does the bloated file shrink?
[5] VACUUM both tables (the garbage collector removes dead tuples): t after VACUUM: live tuples (n_live_tup) = 1 dead tuples (n_dead_tup) = 0 heap size (pg_relation_size) = 8192 bytes (8192 bytes) pgstattuple: tuple_count=1 dead_tuple_count=0 dead%=0.0 free%=93.21 t2 after VACUUM: live tuples (n_live_tup) = 1 dead tuples (n_dead_tup) = 0 heap size (pg_relation_size) = 2818048 bytes (2752 kB) pgstattuple: tuple_count=1 dead_tuple_count=0 dead%=0.0 free%=99.59

Dead tuples → 0 on both, but the bloated file does not shrink. VACUUM found every dead version and marked its space reusablefree% on t2 jumps from 0.05% back to 99.59% — but pg_relation_size still reads 2752 kB. Plain VACUUM reclaims space for future rows in this table, it does not hand pages back to the OS. (VACUUM FULL rewrites the table and would shrink it, at the cost of an ACCESS EXCLUSIVE lock.) The unindexed t was never bloated, so its VACUUM is a no-op on size — it just clears the 158 stray dead tuples.


What you should see

Why

From first principles, the root cause is a single design decision: Postgres never updates a row in place, because a concurrent transaction on an older snapshot may still need to read the old version. Overwriting the bytes would corrupt that reader's view of the world. So MVCC keeps versions: an UPDATE inserts a new tuple carrying the new values and stamps the old tuple's xmax with the updating transaction's id, meaning "deleted by this txn." The old version stays physically present, visible to snapshots that started before the update and dead to everyone after. That is why the ctid moves on every update in Step 1 — a new tuple means a new physical address — and why "one row, 100,000 updates" really produces 100,000 physical versions.

The cost of that design would be runaway write amplification and bloat — each logical update is a physical insert, so versions accumulate — except Postgres has two defenses, and the two tables show them at their extremes. HOT is the front-line defense: when an update changes no indexed column, the index still points correctly at the row's page, so Postgres can chain the new version to the old inside that page and prune dead versions during ordinary access. On the unindexed table that pruning keeps up perfectly — the single page is recycled 100,000 times and the heap never grows past 8 kB (Step 2). The moment an indexed column changes, HOT is off: every update inserts an index entry pointing at the new tuple, the old one can no longer be reached through the in-page prune chain, and it must wait for the second defense — VACUUM, the background garbage collector that scans for tuples no snapshot can see and marks their slots reusable (Step 4). Between the two, dead versions pile up: ~99,421 of them, a 344× heap and a bloated index (Step 3). VACUUM then reclaims the space for reuse, though a plain vacuum won't return it to the OS.

The boundary — bloat tracks un-prunable updates and deletes, not data A table that is only ever INSERTed into and read (append-only) does not bloat: there are no old versions to leave behind. An update-heavy table on unindexed columns rides HOT and stays flat without much vacuuming at all — exactly what you just saw. And a workload where autovacuum keeps up — the default, which we deliberately disabled here — holds n_dead_tup near zero by vacuuming continuously. Bloat becomes a real problem precisely when you combine high update/delete churn on indexed columns (HOT disabled) with vacuuming that can't keep pace — long-running transactions that hold back the snapshot horizon are the classic cause, since they make old versions un-reclaimable no matter how often you vacuum. The lesson the book's one-sentence claim hides: "an update is a delete and an insert" is not a curiosity — it is why which columns you index silently decides whether an update-heavy table stays 8 kB or grows to megabytes.

Go deeper

Sources: DDIA 2e, Ch. 8, §"Multiversion concurrency control" · PostgreSQL 16 manual, "Concurrency Control (MVCC)", "Routine Vacuuming", "Database Physical Storage", pgstattuple.