10 min read

InnoDB Write Amplification vs PostgreSQL HOT Updates

InnoDB and PostgreSQL pay for UPDATEs in completely different currencies. Here is how secondary index maintenance, change buffering, HOT updates, and fillfactor actually behave under an update-heavy workload.

Every migration conversation I have with MySQL teams eventually lands on the same question: "I heard PostgreSQL rewrites the whole row on every UPDATE. Is that going to kill us?" It is a fair question, and the honest answer is: it depends on which columns you update and how your tables are laid out. Both engines amplify writes. They just amplify them in different places, and the tuning levers are completely different.

I have operated update-heavy workloads on both engines. This is the mental model I wish someone had handed me before the first migration.

How InnoDB pays for an UPDATE

InnoDB tables are index-organized. The primary key is the clustered index, and the full row lives inside its leaf pages. When you update a row, InnoDB modifies the row in the clustered index page and writes the previous version into the undo log so that other transactions can still reconstruct the old row. Purge threads clean up undo history later.

Secondary indexes are where the write amplification story starts. Each secondary index entry contains the indexed columns plus the primary key value. If your UPDATE touches a column that is not in any secondary index, those indexes are untouched. If it does touch an indexed column, InnoDB cannot update the entry in place: it delete-marks the old secondary index record and inserts a new one. With five secondary indexes on a hot column, one logical UPDATE becomes one clustered-index change plus up to five delete-mark-and-insert pairs, plus undo, plus redo log for all of it.

The classic mitigation was the change buffer. When a secondary index page is not in the buffer pool, InnoDB could buffer changes to non-unique secondary indexes and merge them later, converting random I/O into batched I/O. Two caveats matter in 2026: the change buffer never applied to unique indexes, and MySQL 8.4 deprecated the feature and changed the innodb_change_buffering default to none. On modern hardware with large buffer pools, Oracle's position is that the complexity no longer pays for itself. If your capacity plan for an update-heavy MySQL workload silently depends on change buffering, that assumption is aging out from under you.

You can see how much change buffer activity you actually have:

SELECT NAME, COUNT
FROM information_schema.INNODB_METRICS
WHERE NAME LIKE 'ibuf%'
  AND COUNT > 0
ORDER BY NAME;

If those counters are near zero on MySQL 8.0, disabling the change buffer before an upgrade to 8.4 is a non-event. If they are large, you want to understand why before the default changes underneath you.

How PostgreSQL pays for an UPDATE

PostgreSQL heap tables are not index-organized. An UPDATE never modifies the row in place in the MVCC sense: it writes a complete new row version into the heap and marks the old version as expired. Old versions are reclaimed later by vacuum. This is the "whole-row versioning" that MySQL folks have heard about, and yes, it means a one-column update to a wide row copies the whole row.

The part that surprises people is index maintenance. By default, every index on the table gets a new entry pointing at the new row version, even indexes on columns you did not change, because the new version lives at a new physical location. That is the write amplification made famous by a certain ride-sharing company's migration post. If PostgreSQL always behaved that way, update-heavy workloads would be rough.

It does not always behave that way, because of HOT: heap-only tuples. If your UPDATE does not modify any indexed column, and the new row version fits on the same heap page as the old one, PostgreSQL creates a heap-only tuple. The indexes keep pointing at the original line pointer, the page carries a small chain from old version to new, and zero index entries are written. Later, page-level pruning cleans up dead versions within the page without waiting for a full vacuum. A HOT update is dramatically cheaper than a non-HOT update, and PostgreSQL 14 added bottom-up index deletion on b-trees to soften the blow even when updates are not HOT.

HOT has two conditions, and you control both

The first condition is schema design: do not index columns you update constantly. Every index on a frequently updated column converts cheap HOT updates into full index maintenance across the whole table. I have seen a single "last_seen_at" index turn a calm table into a bloat factory. The PostgreSQL index advisor mindset applies in both directions: missing indexes hurt reads, but gratuitous indexes on volatile columns hurt every write.

The second condition is page space: the new version must fit on the same page. A freshly filled page has no room, so HOT fails and the new version spills to another page. This is what fillfactor is for. Setting fillfactor below 100 reserves free space in each heap page during inserts, specifically so future updates can stay local.

ALTER TABLE user_sessions SET (fillfactor = 80);

-- Rewrite existing pages so the new setting applies everywhere
VACUUM FULL user_sessions;  -- or pg_repack in production

-- Then watch the HOT ratio over time
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 n_tup_upd > 0
ORDER BY n_tup_upd DESC
LIMIT 20;

PostgreSQL 16 added n_tup_newpage_upd to pg_stat_user_tables, which counts updates that had to move to a new page. If that number is high while your HOT percentage is low on a table where you believe no indexed columns change, the page-space condition is what is failing you, and a lower fillfactor is usually the fix. There is a cost: fillfactor 80 means your table is 25% larger on disk and in cache than it strictly needs to be. For a hot, update-heavy table that trade is almost always worth it. For an append-mostly table it is pure waste, so leave those at 100.

Where each engine actually hurts

InnoDB's pain concentrates in secondary index count and indexed-column volatility. The clustered index means the base row update is cheap and local, and non-indexed column updates are genuinely inexpensive. But every secondary index you add is a standing tax on every update that touches its columns, unique secondary indexes never benefited from change buffering, and long-running transactions inflate undo history, which slows purge and can degrade reads that have to walk long version chains.

PostgreSQL's pain concentrates in vacuum debt and index bloat. Dead row versions accumulate in the heap and in indexes; if autovacuum falls behind, tables and indexes bloat, cache efficiency drops, and everything gets slower in a way that no single query explains. Long-running transactions hurt here too, by holding back the xmin horizon so vacuum cannot reclaim anything. The failure mode is quieter than a locked-up MySQL purge lag, but it is the same disease: MVCC garbage nobody is collecting.

Neither engine forgives an ORM that writes every column on every save. In InnoDB, SET-everything updates touch every secondary index whose columns appear in the statement's changed set. In PostgreSQL, updating an indexed column to the same value still disqualifies HOT in the general case that the column appears modified. Teach your ORM to emit partial updates; it is one of the cheapest wins available on either engine.

Designing an update-heavy table on each engine

On MySQL and InnoDB, my checklist is: keep the primary key small because it is copied into every secondary index; keep the secondary index count ruthless; never index a counter or timestamp that changes on every write unless a query truly needs it; and separate hot volatile columns into a narrow side table if they are dragging index maintenance into an otherwise stable row.

On PostgreSQL, the checklist inverts around HOT: keep volatile columns out of all indexes; set fillfactor 70 to 90 on hot tables and their busiest indexes; watch the HOT ratio and n_tup_newpage_upd; tune autovacuum to be more aggressive on those tables rather than less; and hunt down long transactions, because a single stuck session can quietly disable cleanup for the whole database. The PostgreSQL platform overview covers more of the MVCC background if the vacuum model is new to your team.

The wide-row concern from the start of this article deserves one more note: PostgreSQL stores large values out of line via TOAST, and unchanged TOASTed values are not rewritten on update. A table with a big JSONB document and a few small hot columns updates more cheaply than the "whole row rewrite" phrase suggests, as long as the document itself is not what changes.

What to measure after you migrate

If you move an update-heavy workload from MySQL to PostgreSQL, do not wait for the first bloat incident to start measuring. From day one, track per-table update and HOT-update counts, dead tuple accumulation, autovacuum runs and durations, index sizes over time, and the age of your oldest transaction. Regressions here are slow-motion: the workload looks fine for weeks while debt accumulates, then p99 latency drifts and everyone blames the application.

Query-level history matters just as much. An update statement whose plan flips from a HOT-friendly single-page change to an index-maintenance storm will show up in slow query monitoring long before it shows up in disk graphs.

How MonPG helps once you are on PostgreSQL

MonPG monitors PostgreSQL only, so it will not watch your MySQL side during the transition. What it does after cutover is exactly the evidence trail this article argues for: per-table update and HOT ratios, dead tuple and bloat trends, autovacuum activity, index usage and size growth, long-transaction detection, and query-family history from pg_stat_statements, all in one workflow. When a deploy adds an index on a volatile column and your HOT percentage falls off a cliff, that should be a graph you notice the same week, not a forensic discovery six months later. The PostgreSQL monitoring guide shows how those signals fit together in practice.