DeepConcepts

Postgres / storage / heap / updates

Heap-Only Tuple Updates and fillfactor

The misconception

That an UPDATE only writes to the indexes whose columns it changed, and that Postgres decides this per index. It is one all-or-nothing test per statement: change a single column that any non-summarizing index references and every index on the table gets a new entry, including the six that index columns you did not touch. And even an update that touches no indexed column falls back to that same cold path whenever the old row's page has no free space — which at the default fillfactor of 100 is most pages, most of the time.

16 min

An UPDATE in Postgres normally writes one new entry into every index on the table — not into the indexes whose columns you changed, into all of them. The Heap-Only Tuple optimisation, HOT, is the escape hatch that writes into none of them. It has exactly two conditions, and the second one is about free space on a single 8 kB page.

Postgres never updates a row in place. It writes a whole new version and leaves the old one behind, which is the subject of MVCC and vacuum. The part that costs you on an update-heavy table is not the extra heap row. It is that every index normally has to learn where the new version lives, so a table with six indexes pays six index writes for one UPDATE — six more leaf pages dirtied, six more entries in the write-ahead log, six more places for the old entry to sit dead until vacuum removes it.

A Heap-Only Tuple is a row version with no index entry of its own. The indexes keep pointing at the original version's slot on the page, and a forward link inside the page leads from there to the new version. Postgres can do this when both of these hold:

  • the statement changes no column referenced by any index on the table, except summarizing indexes — BRIN is the only summarizing index type in core Postgres; and
  • the new version fits in the free space on the page the old version is already sitting on.

The panel loads a 100,000-row table, then runs an UPDATE workload against it and reports what each statement was allowed to do. The slider that carries the lesson is fillfactor, the percentage of each page that INSERT is permitted to fill. It starts at 100, the default, which reserves nothing.

Oldest snapshot lag is how far behind the newest transaction the oldest still-open snapshot in the database is, measured in statements. A dead row version cannot be reclaimed until nothing can see it, so at a lag of 2,000 there are always about 2,000 unreclaimable dead versions spread across the table, occupying space that a HOT update would otherwise use. Set it to 0 and you get the single-session laboratory case where every dead version is reclaimable the instant it is created. Real systems are never there. A vacuum round runs every 50,000 statements and reclaims the dead line pointers and index entries that pruning cannot.

updates that were HOT
index entries per UPDATE
index entries written
heap on disk
heap growth over the load
n_tup_newpage_upd
Index entries written, per 5,000 statements

Inside 60 heap pages, sampled evenly across the file

live row versions · dead versions not yet pruned · free space, which is the only thing a HOT update can use

The page arithmetic is real: 8,192-byte pages, a 24-byte page header, a 4-byte line pointer per row version, and a row version of MAXALIGN(24 + data) bytes. The same-page fit test carries no fillfactor reserve and the other-page one does, which is what heap_update does. Four things are simplified and are called out again below: pruning is assumed to win its buffer cleanup lock every time; the snapshot lag is a flat window rather than a real set of snapshots; a modelled vacuum round every 50,000 statements reclaims dead line pointers and index entries, but never truncates the file; and index size is derived from a flat 367 entries per 8 kB leaf page, ignoring page splits and bottom-up index deletion. Sizes are a model of the mechanism, not a benchmark.

At the defaults — fillfactor 100, a column no index mentions — 64.5% of the statements manage a HOT update and the other 71,097 write 355,485 index entries between them, 1.78 per statement on a table that has five indexes and a workload that logically changes none of them. Drag fillfactor from 100 to 90 and that number goes to 0.00. The whole cost disappears, and the price is a table that starts out 13.2 MB instead of 12.0 MB.

Now put fillfactor back to 100 and switch the column to status. The HOT rate drops to 0.0%, index writes go to 1,000,000 — five per statement, exactly the number of indexes — and now drag fillfactor down through its whole range. The rate stays at 0.0% the entire way. That is the shape of the lesson. One condition is a space problem you can tune. The other is a schema fact you cannot.

The condition that is not about space

With status selected, drag fillfactor all the way down to 10. The heap goes from 12.0 MB to 130.2 MB — eleven times the size, for a table holding exactly the same 100,000 rows — and the HOT rate stays at exactly 0.0%. Nothing you do to page layout can buy back a HOT update that the column test already refused.

The test is one line of heap_update: if (!bms_overlap(modified_attrs, hot_attrs)). Both operands are bitmaps over the table's columns. modified_attrs is the set of columns whose stored bytes this statement actually changed — bitwise, so writing the same value back does not count as a modification and stays HOT-eligible. hot_attrs is the union of every column referenced by every non-summarizing index on the table.

"Referenced" is broader than people expect, and each of these puts a column into hot_attrs:

  • a key column of any B-tree, GiST, GIN, SP-GiST or hash index;
  • a column in an <code>INCLUDE</code> clause, even though it is not a key and cannot be searched on;
  • a column used only inside a partial index's WHERE predicate, which does not appear in the index at all;
  • a column that feeds an expression index, such as lower(email) — the index stores the result, but the input column is what is tracked;
  • a column in a unique constraint, primary key or exclusion constraint, because each of those is backed by an index.

Only BRIN escapes. BRIN stores a summary per block range rather than one entry per row, so it has no per-row pointer to maintain and cannot go stale in the way that matters here. The ingested_at option in the panel is exactly that case: the column is indexed, the first HOT condition still passes, and the BRIN summary is refreshed without a single B-tree write.

The consequence of failing the test is the part that surprises people. Because the new version gets its own line pointer, every index has to be able to find it, so the executor inserts into all of them. Add a seventh index to a table for one reporting query, and every non-HOT UPDATE on that table becomes 17% more expensive even if the query never runs and the new index shares no column with anything you write. Move the indexes on the table slider with status selected and watch index entries per UPDATE track it exactly.

The condition that is about space, and why fillfactor is the knob

Put the column back to last_seen_at and leave fillfactor at 100. The HOT rate is not zero — it is 64.5%. That surprises people who have read that fillfactor 100 disables HOT. Following why it is 64.5% rather than either 0% or 100% is the whole of the space condition.

A page loaded at fillfactor 100 holds 65 row versions of 120 bytes each and has 108 bytes left over. A new version needs 124: 120 for the tuple and 4 for its line pointer. So the first UPDATE to touch any freshly loaded page cannot be HOT. The row is written on some other page, gets its index entries, and leaves a dead version behind.

That dead version is 120 bytes the page can have back — eventually. heap_page_prune_opt runs whenever a page is read and has less free space than MAX(fillfactor target free space, BLCKSZ/10), which at fillfactor 100 is 819 bytes, so a page sitting at 108 free is always a candidate. Set oldest snapshot lag to 0 and you can watch what happens when pruning always succeeds: the HOT rate goes to 99.2%, one failure per page for the whole run, and the heap grows 2%. Every page pays for one migration and is then self-sustaining forever, recycling a single 120-byte slot between the dying version and the new one.

That is the documentation's claim that HOT updates still happen at the default fillfactor "because new rows will naturally migrate to new pages and existing pages with sufficient free space". It is true, and it describes a database with one session in it.

Pruning may only remove a version that no open snapshot can still see. At a lag of 2,000 statements there are always about 2,000 dead versions in the table that pruning is not allowed to touch, spread over 1,539 pages, so a typical page is carrying one or two of them at any moment. It has 108 bytes of slack and 120 to 240 bytes of untouchable garbage. Every second or third update to that page therefore migrates. The decision log counts it: pruning ran into the snapshot lag 121,190 times, and that is where the 35.5% of statements that were not HOT went.

Lowering fillfactor buys headroom that survives the lag. At fillfactor 90 the load packs 59 rows per page and leaves 852 bytes — six row versions of headroom — and the HOT rate goes to 100.0%, index writes to 105 for the whole run, heap growth to nothing. The price was paid in advance: the initial load is 13.2 MB instead of 12.0 MB. That is the actual trade. You are not saving space by lowering fillfactor; you are spending space to avoid index writes.

The fix has a boundary, and the lag slider is how you find it. Leave fillfactor at 90 and push the lag to 20,000 — one moderately long reporting query is worth far more than that on a busy table. The six reserved slots per page are now facing about twelve untouchable dead versions each. The HOT rate collapses to 28.9%, index writes climb to 711,445, and the heap doubles, from 13.2 MB to 27.2 MB. Drop fillfactor to 70 at the same lag and it recovers to 99.2% — so the fix still works, it has simply become much more expensive. This is the same pinned-horizon failure that VACUUM has, arriving through a different door: pruning obeys the same visibility test vacuum does, so the transaction somebody left open in a psql window is silently setting your table's HOT rate.

Two more things about the trade are easy to get wrong.

First, the reserve is per-page and shared, not per-row. A page holding 59 rows with room for six versions supports six unprunable dead versions across all 59 of those rows, not six updates per row. That is why the lag matters so much, and it is why row width matters too. Widen the row width slider to 400 bytes: the fillfactor-90 page still reserves 819 bytes, but a row version now costs 428, so the headroom falls from six spare slots to two and the HOT rate slips from 100.0% to 98.7%. At fillfactor 100 the same page has 36 bytes spare and manages 61.2%. fillfactor is a percentage; HOT needs whole row versions. Fat rows need a lower fillfactor to buy the same number of slots.

Second, ALTER TABLE ... SET (fillfactor = 90) does not reorganise anything. fillfactor is consulted when a row version is placed on a page, so it governs pages built after the change. The pages you already have stay packed to 8,168 bytes and will never spontaneously make room. To apply it you have to rewrite the table — VACUUM FULL, CLUSTER, or pg_repack if you cannot take the ACCESS EXCLUSIVE lock. Setting the parameter and watching nothing change for a week is the single most common way this tuning attempt fails.

Where the space goes when HOT does not happen

A non-HOT update does not necessarily grow the heap. Select status and set fillfactor to 90, then read the decision log's outcome line: of 200,000 cold updates, 199,926 still put the new version on the same page. They had the room. They were simply not allowed to skip the index writes. Postgres asks the two questions separately, and so should you. Same-page placement is a free-space question. Skipping the indexes is a column-overlap question. n_tup_newpage_upd, which PostgreSQL 16 added to pg_stat_all_tables, counts only the first kind of failure — which is exactly what makes it the diagnostic that tells the two apart.

When a version does have to move, Postgres asks the free space map for a page with at least tuple size + fillfactor reserve free, and extends the file if there is none. The reserve applies here and not to the same-page case, which is a detail with a visible consequence: at a low fillfactor a page can be simultaneously eligible to receive a HOT update from a row already on it and ineligible to receive a migrating row from elsewhere. Postgres protects the reserve against newcomers and spends it on residents.

Pruning is what keeps the page map from filling up with red, and it is worth being precise about how much work it is doing: at the defaults it reclaims 195,861 dead row versions over the run, 22.4 MB, without a single vacuum and without touching an index. That is HOT's second gift and it is larger than the first. Vacuum is left with only the line pointers and the index entries — which is why a HOT-friendly table can survive on a much lazier autovacuum schedule than a HOT-hostile one of the same size.

The index entries themselves are the part that does not recover on its own. Autovacuum deletes the entries pointing at removed heap tuples and marks the leaf pages reusable, but a B-tree never merges partly-full pages and never returns them to the operating system, so a burst of non-HOT updates leaves an index that is permanently larger than its contents — see index bloat. PostgreSQL 14 added a strong mitigation here, bottom-up index deletion: when a leaf page is about to split because of version churn, nbtree first tries to delete the obsolete duplicate versions on that page, and usually succeeds. It targets precisely the indexes that were not logically modified by the UPDATE — the innocent bystanders in the "all indexes get an entry" rule. It is not modelled in the panel, so treat the index sizes there as the pre-14 worst case for the B-trees that share no column with the update.

Checking it on a real system

Start with the ratio, per table. This is the one number that tells you whether any of this applies to you:

SELECT relname,
       n_tup_upd,
       n_tup_hot_upd,
       n_tup_newpage_upd,                       -- PostgreSQL 16 and later
       round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC
LIMIT 20;

A table with millions of updates and hot_pct near zero is paying full index write amplification on every statement. Before you reach for fillfactor, find out which condition is failing, because the two have completely different fixes. On PostgreSQL 16 and later n_tup_newpage_upd answers it directly: if it is close to n_tup_upd - n_tup_hot_upd, your problem is space and fillfactor is the lever. If it is near zero while hot_pct is also near zero, the new versions are fitting on their pages perfectly well and you are failing the column test.

Then find out which column. This lists the columns that are in hot_attrs for a table — the ones whose modification forfeits HOT:

SELECT DISTINCT a.attname, i.relname AS index_name, am.amname
FROM pg_index x
JOIN pg_class i     ON i.oid = x.indexrelid
JOIN pg_class t     ON t.oid = x.indrelid
JOIN pg_am    am    ON am.oid = i.relam
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY (x.indkey)
WHERE t.relname = 'orders'
  AND am.amname <> 'brin'      -- the only summarizing AM in core, so the only one that does not block HOT
ORDER BY a.attname;

indkey covers key and INCLUDE columns. Columns that appear only in an expression or a partial-index predicate show up as attribute number 0 in indkey and have to be read out of pg_get_indexdef(x.indexrelid) instead — which is worth doing, because an expression index on a column you update every second is a completely invisible way to lose HOT.

Cross that list against the columns your hot statement writes. The fix, in order of preference: stop writing the column if the write is spurious (SET updated_at = now() on a row whose other columns did not change is the classic); drop the index if nothing needs it — check pg_stat_user_indexes.idx_scan first; or move the churning column into a narrow side table keyed by the same primary key, so the churn happens on a table with one index instead of seven.

Only when the column test is passing and n_tup_newpage_upd is high does fillfactor become the answer:

ALTER TABLE orders SET (fillfactor = 85);
-- existing pages are unaffected until the table is rewritten:
VACUUM FULL orders;        -- ACCESS EXCLUSIVE, needs 2x the space
-- or, without the outage:
pg_repack -t orders

Verify with a before-and-after on hot_pct, having reset the counters with pg_stat_reset_single_table_counters('orders'::regclass) so you are comparing the new steady state rather than the table's whole history. 85 to 90 is the usual landing spot; below about 70 you are paying more in extra pages read per sequential scan than you are saving in index writes, and the panel's heap on disk readout at fillfactor 10 shows how far that can go.

A table has seven indexes. One is on status; the other six are on completely unrelated columns. Your hot statement is UPDATE jobs SET status = 'done', finished_at = now() WHERE id = $1. You set fillfactor = 70 and rewrite the table with pg_repack. What happens to n_tup_hot_upd?

Next: what happens to those index entries afterwards, in why a B-tree never shrinks; and the visibility rule that all of this sits on top of, in MVCC and what VACUUM does not do.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.