Chapter 08 · Heap Storage, TOAST, HOT Updates, Bloat, and Page-Level Internals

HOT Updates, Fillfactor, Index Churn, and Update-Heavy Table Design

Measure Heap-Only Tuple update behavior, understand fillfactor as an opportunity rather than a guarantee, and connect indexed-column changes to index-maintenance and update-heavy table design.

Intermediate → Advanced150–190 minutesHOT eligibility + statistics labCurrent patched PostgreSQL 18.xpageinspect/pg_visibility/pg_freespacemap supplied extensions where indicatedLocal superuser required for raw-page inspectionNo paid or managed-service dependencyLast reviewed: August 2026

Learning outcomes

MVCC means an UPDATE creates a new heap tuple version. Without another optimization, that can also require new entries in every index, even when the indexed key did not change. PostgreSQL's Heap-Only Tuple (HOT) optimization can avoid ordinary index maintenance when the update is eligible and the successor tuple fits on the same heap page.

01

Define HOT eligibility and explain both requirements: unchanged columns referenced by non-summary indexes and sufficient same-page space.

02

Use fillfactor to create page-space opportunity without calling it a guarantee or universal tuning percentage.

03

Measure update and HOT-update counters as deltas rather than reading one cumulative number in isolation.

04

Demonstrate how changing a B-tree-indexed column makes that update non-HOT.

05

Design update-heavy tables by balancing page density, table size, read locality, index count and update patterns.

1. What HOT changes—and what it does not

A HOT update still creates a new tuple version; it is not an in-place overwrite of the row. The optimization is that ordinary indexes can continue to lead to the HOT chain's root and PostgreSQL follows the chain to an appropriate version. Intermediate dead versions can also be pruned during normal page access. In PostgreSQL 18, summary indexes such as BRIN are a special case and can still need maintenance.

Condition Why it matters
Updated columns are not referenced by ordinary indexes Otherwise PostgreSQL needs new searchable index entries for changed indexed values.
New tuple version fits on the same heap page HOT chains are page-local; moving the successor to another page breaks classic HOT eligibility.
Enough free space exists at the moment of update Fillfactor can reserve space, but concurrent inserts/updates and tuple growth determine reality.
Measure actual counters Schema intent is not execution evidence; verify n_tup_hot_upd.

2. Build an update-heavy ServiceHub table

sql · setup with intentional free space
DROP TABLE IF EXISTS app.ch08_hot_probe;CREATE TABLE app.ch08_hot_probe (    work_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,    external_ref text NOT NULL UNIQUE,    status text NOT NULL,    attempt_count integer NOT NULL DEFAULT 0,    note text NOT NULL) WITH (fillfactor = 70);INSERT INTO app.ch08_hot_probe(external_ref, status, note)SELECT 'EXT-' || lpad(g::text, 6, '0'),       'queued',       repeat('x', 120)FROM generate_series(1, 5000) AS g;ANALYZE app.ch08_hot_probe;

Because the primary key and external_ref are indexed, updates to status, attempt_count, or note are candidates for HOT; updates to work_id or external_ref are not. Candidate does not mean guaranteed.

3. Capture a baseline, perform non-indexed updates, then compare deltas

sql · baseline cumulative counters
SELECT n_tup_upd, n_tup_hot_upd, n_live_tup, n_dead_tupFROM pg_stat_user_tablesWHERE relid = 'app.ch08_hot_probe'::regclass;SELECT stats_resetFROM pg_stat_databaseWHERE datname = current_database();
sql · candidate HOT workload
UPDATE app.ch08_hot_probeSET attempt_count = attempt_count + 1,    status = CASE WHEN work_id % 5 = 0 THEN 'retry' ELSE status ENDWHERE work_id BETWEEN 1 AND 3000;SELECT pg_stat_clear_snapshot();SELECT n_tup_upd, n_tup_hot_upd, n_dead_tupFROM pg_stat_user_tablesWHERE relid = 'app.ch08_hot_probe'::regclass;

Record the before and after values and calculate deltas. n_tup_upd includes HOT updates, while n_tup_hot_upd counts HOT updates. pg_stat_clear_snapshot() only discards this session's cached statistics snapshot; it does not force another backend to publish counters. Cumulative statistics can lag briefly, so if a just-finished workload has not appeared yet, end the workload transaction, let that backend become idle, and read the counters again rather than inventing an exact immediate value. Exact ratios are workload-dependent. A 70% fillfactor does not promise a 70% or 100% HOT rate.

4. Make a B-tree-indexed value change: HOT is no longer eligible for those rows

sql · indexed-column update
UPDATE app.ch08_hot_probeSET external_ref = external_ref || '-R'WHERE work_id BETWEEN 4001 AND 4100;SELECT pg_stat_clear_snapshot();SELECT n_tup_upd, n_tup_hot_upd, n_dead_tupFROM pg_stat_user_tablesWHERE relid = 'app.ch08_hot_probe'::regclass;

Those 100 updates change an indexed B-tree key. They require index maintenance and are not classic HOT updates. As above, clearing the local statistics snapshot is not a forced statistics flush; compare eventual deltas after the workload backend has had a chance to publish its counters. The cumulative totals also include earlier work, which is why production dashboards should compare rates over an interval and correlate them with application update patterns.

5. Fillfactor trades density for update headroom

Table fillfactor defaults to 100. A lower value tells inserts to pack pages only to the specified percentage, reserving room for later row versions. This increases the chance of same-page updates, but reduces initial heap density and can increase scan footprint. There is no universal best fillfactor.

sql · inspect table storage policy
SELECT c.relname, c.reloptions,       pg_size_pretty(pg_relation_size(c.oid)) AS heap_main,       pg_size_pretty(pg_indexes_size(c.oid)) AS indexesFROM pg_class AS cWHERE c.oid = 'app.ch08_hot_probe'::regclass;

Changing fillfactor on an already-packed table changes the policy for future packing; it does not instantly redistribute old tuples. A rewrite can repack existing data, but that has locking, I/O, WAL and disk-space implications and belongs in a planned maintenance decision.

6. HOT chains are page-local and pruning can shorten them

When repeated eligible updates occur on the same page, PostgreSQL can form a HOT chain. Ordinary indexes keep a reference that can lead into the chain, and heap access follows the chain to a version visible to the current snapshot. Once intermediate versions can no longer be seen by any relevant snapshot, page pruning can remove tuple bodies and convert line pointers into redirect/reusable states. This is one reason HOT reduces both index churn and later cleanup work.

Do not infer chain length from n_tup_hot_upd. The counter tells you how many updates were HOT, while pruning can shorten physical chains over time. Lesson 5 uses pageinspect to observe one deliberately small example.

7. “Indexed column” includes dependencies you might not see in a simple column list

HOT eligibility is affected by columns referenced by expression indexes and partial-index predicates, not only plain CREATE INDEX ... (column) keys. An application team can accidentally reduce HOT opportunity by adding a convenient expression index over a frequently changing JSON/text field.

sql · example dependency that would matter to HOT eligibility
-- Conceptual example; do not add it to the lab table unless you want to test the effect.CREATE INDEX ch08_hot_status_lower_idxON app.ch08_hot_probe ((lower(status)));-- This status change now touches an expression-index input.UPDATE app.ch08_hot_probeSET status = 'done'WHERE work_id = 42;DROP INDEX app.ch08_hot_status_lower_idx;

The optimizer/index definition and heap-update machinery care about the indexed expression's attribute dependencies. Review pg_indexes.indexdef and application write patterns together when diagnosing a low HOT rate.

8. Measure HOT as an interval rate, then correlate it with business operations

A cumulative 80% HOT ratio can hide a regression if the last deployment changed a key column on every retry. Capture counters at two times, subtract them, and annotate the interval with workload/deployment context. Also inspect the database statistics-reset timestamp so a restart/manual reset does not masquerade as a drop in activity.

sql · interval measurement pattern
-- Snapshot A (store these values in your monitoring system).SELECT clock_timestamp() AS captured_at, n_tup_upd, n_tup_hot_updFROM pg_stat_user_tablesWHERE relid = 'app.ch08_hot_probe'::regclass;SELECT stats_resetFROM pg_stat_databaseWHERE datname = current_database();-- Later: capture the same counters and compute deltas outside this query/lab.

For a real performance experiment, declare dataset size, update mix, indexes, fillfactor, cache state, concurrency, storage and PostgreSQL build. HOT is a mechanism measurement, not a benchmark score that transfers between systems.

9. Wrong approaches that misdiagnose HOT

  • “The updated value did not change, so HOT must occur.” PostgreSQL's index dependency and executor/storage conditions matter; do not infer from business semantics alone.
  • “No directly indexed column changed.” Expression-index inputs and partial-index predicates can make columns relevant to index maintenance. Inspect definitions, not just column lists.
  • “Lower fillfactor guarantees HOT.” The successor must still fit on the old page at update time.
  • “More HOT is always better.” Extreme free-space reservation can make read-heavy tables larger and less cache-efficient.
sql · inspect index definitions before judging eligibility
SELECT indexname, indexdefFROM pg_indexesWHERE schemaname = 'app'  AND tablename = 'ch08_hot_probe'ORDER BY indexname;

10. Reproducible verification and cleanup

sql · final evidence
SELECT n_tup_ins, n_tup_upd, n_tup_hot_upd, n_tup_del,       n_live_tup, n_dead_tup,       CASE WHEN n_tup_upd > 0            THEN round(100.0 * n_tup_hot_upd / n_tup_upd, 2)       END AS cumulative_hot_pctFROM pg_stat_user_tablesWHERE relid = 'app.ch08_hot_probe'::regclass;

The percentage is cumulative since the statistics reset, not a pure measurement of the immediately preceding statement. In production, capture interval deltas and reset context before drawing conclusions.

sql · cleanup
DROP TABLE IF EXISTS app.ch08_hot_probe;

Check your understanding

  1. What two conditions are required for a classic HOT update?
  2. Does HOT mean PostgreSQL overwrote the original tuple in place?
  3. Why can a lower fillfactor increase HOT opportunity?
  4. Why should you compare pg_stat counters as deltas?
  5. What happens to HOT eligibility when a B-tree-indexed key changes?
Review the answers

The update must avoid changing columns referenced by ordinary indexes and the new version must fit on the same heap page. HOT still creates a new tuple version. Lower fillfactor reserves page space but trades density for headroom. Statistics are cumulative and need interval/reset context. Changing a B-tree-indexed value requires index maintenance and makes that update non-HOT.

11. Production judgment and bridge

Use HOT rate as one signal in an update-heavy design, not as a target divorced from workload. Fewer indexes, stable indexed keys, controlled tuple growth, and appropriate fillfactor can help, but read amplification and space cost matter too. Lesson 4 follows the versions that updates/deletes leave behind: how reusable dead space becomes operationally significant bloat and when a rewrite is justified.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only assets compatible with the Ethereum/ERC-20 network. Do not send TRC-20/TRON assets.