PostgreSQLVII · MVCC, Transactions and VisibilityMVCC
What UPDATE actually does to a page
What you'll learn
- State the two conditions for a HOT update and check whether a table is getting them
- Measure the WAL cost of non-HOT updates rather than estimating it
- Reason about fillfactor as a trade rather than as a tuning knob
- Explain why an index on a frequently updated column costs more than its own storage
Prerequisites
Practice
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
Part VI showed what an UPDATE does to a page: a new tuple appears, the
old one is stamped with t_xmax and pointed forward. This lesson is
about the difference between the cheap version of that and the expensive
one, because the gap is large and the condition that decides it is under
your control.
The two conditions
An update is a HOT update — heap-only tuple — when both of these hold:
- No indexed column changed.
- The new version fits on the same page.
When both hold, the new tuple is chained to the old one inside the page and no index is touched at all. The index entries still point at the old line pointer, and a scan arriving there follows the chain forward.
When either fails, the new version needs an entry in every index on the table, whether or not that index has anything to do with the column you changed.
That last clause is the one people underestimate. Change one non-indexed column on a table with six indexes, on a full page, and you have written six index entries.
Measuring the difference
Two tables, identical in every respect except fillfactor, each with
20,000 rows and a primary key on id. The updated column, counter, is
not indexed — so the only thing standing between these updates and HOT
is whether the new tuple fits on the page.
$ psql -U postgres -c "SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS heap, pg_size_pretty(pg_indexes_size(oid)) AS indexes FROM pg_class WHERE relname IN ('hot_default','hot_ff') ORDER BY relname" relname | heap | indexes
-------------+---------+---------
hot_default | 1016 kB | 456 kB
hot_ff | 1464 kB | 456 kB
(2 rows)fillfactor = 70 costs 44% more heap up front. That is the price. Now
twenty rounds of UPDATE … SET counter = counter + 1, with a VACUUM
between rounds so that neither table is simply drowning in dead tuples:
$ psql -U postgres -c "SELECT relname, n_tup_upd, n_tup_hot_upd, round(100.0*n_tup_hot_upd/nullif(n_tup_upd,0),1) AS hot_pct FROM pg_stat_user_tables WHERE relname IN ('hot_default','hot_ff') ORDER BY relname" relname | n_tup_upd | n_tup_hot_upd | hot_pct
-------------+-----------+---------------+---------
hot_default | 400000 | 2694 | 0.7
hot_ff | 400000 | 378716 | 94.7
(2 rows)0.7% against 94.7%, from one storage parameter.
But look at the sizes, because they do not tell the story you might expect:
$ psql -U postgres -c "SELECT relname, pg_size_pretty(pg_relation_size(oid)) AS heap, pg_size_pretty(pg_indexes_size(oid)) AS indexes FROM pg_class WHERE relname IN ('hot_default','hot_ff') ORDER BY relname" relname | heap | indexes
-------------+---------+---------
hot_default | 2032 kB | 896 kB
hot_ff | 2784 kB | 888 kB
(2 rows)With vacuum keeping up, the two tables end at broadly similar sizes.
hot_ff is actually the larger of the two, because of the space it
reserved. So on size alone, in a healthy system, HOT looks like it
barely matters.
The cost is somewhere else.
Where the cost actually lands
The right measurement is WAL volume, because WAL is what leaves the
machine. Measured as the difference in pg_current_wal_lsn() across a
single full-table update of 20,000 rows:
$ psql -U postgres -At -c "SELECT pg_current_wal_lsn()" # before and after each UPDATE round 1 hot_default (fillfactor 100): 4262 kB (4364624 bytes)
round 1 hot_ff (fillfactor 70): 1413 kB (1447040 bytes)
round 2 hot_default (fillfactor 100): 4382 kB (4486768 bytes)
round 2 hot_ff (fillfactor 70): 1548 kB (1585024 bytes)
round 3 hot_default (fillfactor 100): 4384 kB (4489272 bytes)
round 3 hot_ff (fillfactor 70): 1548 kB (1585040 bytes)2.8 times the WAL for identical logical work, and stable across rounds.
An index on the updated column ends HOT entirely
The second condition is the more absolute one. Same table, same
fillfactor = 70, same UPDATE — with one index added on counter,
the column being updated.
$ psql -U postgres -c "CREATE INDEX hot_ff_counter_idx ON hot_ff (counter)" -c "VACUUM ANALYZE hot_ff"-- before the index: 94.7% HOT, 1548 kB WAL per round
-- after the index:
wal_for_one_round
-------------------
3984 kB
relname | n_tup_upd | n_tup_hot_upd | hot_pct
---------+-----------+---------------+---------
hot_ff | 20000 | 0 | 0.0From 94.7% to zero, and WAL multiplied by 2.6. No amount of
fillfactor recovers this, because the first condition is not about
space — the index entry for the old value is now wrong, so a new one
must be written no matter where the tuple goes.
Reading your own tables
The numbers are already being collected. Nothing needs to be enabled.
SELECT relname,
n_tup_upd,
n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_pct,
n_dead_tup,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC
LIMIT 20;
Reading it:
- High
n_tup_upd, highhot_pct. Working as intended. Leave it alone. - High
n_tup_upd, lowhot_pct. Every update is writing index entries. Establish which condition is failing: is an indexed column changing, or are the pages full? The first is a schema question, the second is afillfactorquestion. - High
n_tup_upd, lowhot_pct, and the columns being updated are not indexed. This is the casefillfactorfixes. It is also the case where lowering it is most clearly worth the extra space.
What to take from this
- HOT requires both that no indexed column changed and that the new version fits on the page. Failing either writes an entry in every index.
- The cost of non-HOT updates shows up in WAL volume, not primarily in table size — measured here at 2.8x, and 2.6x from adding one index.
- WAL is a shared, remote cost: replication, archiving, recovery time.
n_tup_hot_updagainstn_tup_updtells you where you stand, on statistics already being collected.fillfactoronly helps while vacuum is reclaiming the space it reserves, and only applies to pages written after it is set.
Cross-course references
- Linux for Production Sysadmins — Part XLI (Storage Performance) covers the device-level cost of the extra page writes a non-HOT update produces.
- Observability for Production Sysadmins — Part LIX (Database
observability) covers tracking the HOT-update ratio over time, which
is the number that tells you whether
fillfactoris working.
Quiz
Knowledge check · 6 questions
Q1. A table has six indexes, none of them on the column being updated. Its pages are completely full. What does one UPDATE of that column cost in index terms?
Q2. Replication lag on a busy cluster began growing last week. Query patterns are unchanged, the primary is not CPU-bound, and the replica is not CPU-bound either. A colleague added an index the previous Friday. What is the most likely mechanism?
Q3. A high-churn table is set to fillfactor = 70. Autovacuum on that table is failing to keep up. What should be expected?
Q4. Which statements about HOT updates are correct? Select all that apply.
Q5. Because a healthy vacuum keeps both tables at a similar size, the main cost of non-HOT updates is best measured in WAL volume rather than in table size.
Q6. You are asked to approve an index on a column that a query filters on. What write-side questions should you ask before agreeing, and what would you measure?
Passing score: 75%. Answers are checked in this browser.