Skip to main content
RunBook Academy

PostgreSQLVII · MVCC, Transactions and VisibilityMVCC

What UPDATE actually does to a page

Advanced⏱ ~30 min🧪 Lab requiredpsql

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

Not yet marked complete on this device.

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:

  1. No indexed column changed.
  2. 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.

Read-only / Safebaseline after load and VACUUM ANALYZE
$ 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:

Read-only / SafeHOT rate after 400,000 updates on each table
$ 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:

Read-only / Safesizes after the same 400,000 updates
$ 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:

Read-only / SafeWAL generated by one identical UPDATE statement
$ 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.

Configuration changethe effect of adding one index to the updated column
$ 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.0

From 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, high hot_pct. Working as intended. Leave it alone.
  • High n_tup_upd, low hot_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 a fillfactor question.
  • High n_tup_upd, low hot_pct, and the columns being updated are not indexed. This is the case fillfactor fixes. 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_upd against n_tup_upd tells you where you stand, on statistics already being collected.
  • fillfactor only 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 fillfactor is working.

Quiz

Knowledge check · 6 questions

  1. 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?

  2. 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?

  3. Q3. A high-churn table is set to fillfactor = 70. Autovacuum on that table is failing to keep up. What should be expected?

  4. Q4. Which statements about HOT updates are correct? Select all that apply.

  5. 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.

  6. 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.