awesome-everything RU
↑ Back to the climb

Databases

HOT updates and isolation levels: what you gain and what you pay

Crux HOT updates cut index write cost by up to 70% when conditions are right; the four isolation levels decide which concurrency anomalies the database prevents vs leaves to the application.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at middle altitude — in the sky
◷ 16 min

Every UPDATE in Postgres writes a new tuple. If the table has five indexes, each UPDATE writes five new index entries. HOT updates eliminate that cost — but only under two specific conditions. And the isolation level you pick determines which correctness bugs the database prevents vs which it leaves to your application code.

HOT updates: an optimization with a famous cost model

If an UPDATE touches no indexed column and the new tuple fits on the same heap page as the old, Postgres can perform a heap-only tuple (HOT) update:

  • The old tuple gets flag HEAP_HOT_UPDATED — “follow the t_ctid chain to find me”
  • The new tuple gets flag HEAP_ONLY_TUPLE — “no index entry points at me directly”
  • The index continues pointing at the original tuple; the heap chain carries the lookup the rest of the way
  • Result: two index writes become zero

On UPDATE-heavy workloads where most columns are not indexed, this is the difference between WAL doubling and barely growing — a 30–70% WAL savings.

HOT chains break when the new version cannot fit on the original page; once broken, every subsequent UPDATE pays the index-write cost again. The fillfactor storage parameter (ALTER TABLE t SET (fillfactor=85)) reserves free space on each page precisely so HOT chains have room to grow.

ConditionHOT eligible?Index writes
No indexed column changed, new tuple fits same pageYes0
No indexed column changed, but page is fullNo (chain breaks)1 per index
Indexed column changedNo1 per index
Why this works

pg_stat_all_tables.n_tup_hot_upd divided by n_tup_upd gives the HOT ratio. On UPDATE-heavy schemas you want this above 80%. Below 50% means fillfactor is too aggressive or the schema has the wrong index set for the workload.

The four isolation levels Postgres exposes

SQL standard defines four levels by listing anomalies they allow or prohibit: dirty reads, non-repeatable reads, phantom reads, and serialization anomalies.

LevelDirty readNon-repeatable readPhantom readWrite skew
READ UNCOMMITTEDPossible (Postgres: no)PossiblePossiblePossible
READ COMMITTED (default)NoPossiblePossiblePossible
REPEATABLE READNoNoNo (PG)Possible
SERIALIZABLENoNoNoNo

Postgres specifics:

  • Read Uncommitted is silently treated as Read Committed; Postgres never allows dirty reads at any level.
  • Read Committed (the default) gives each statement a fresh snapshot.
  • Repeatable Read gives the whole transaction one snapshot taken at the first statement; Postgres implements RR as snapshot isolation — stronger than the SQL standard’s RR (no phantom reads) but weaker than true serializable (write skew is still possible).
  • Serializable (since 9.1) layers Serializable Snapshot Isolation (SSI) on top of RR: Postgres tracks read–write dependencies and aborts transactions whose commit order cannot correspond to any serial schedule, throwing SQLSTATE 40001.

What the application must do

Under READ COMMITTED: the application is responsible for avoiding lost updates via:

  • SELECT ... FOR UPDATE (pessimistic locking on the row), or
  • UPDATE ... WHERE version = ? with optimistic concurrency, or
  • RETURNING to confirm the row had the expected value.

Under REPEATABLE READ and SERIALIZABLE: the database raises SQLSTATE 40001 serialization_failure when a conflict is detected. The application’s only correct response is to retry the entire transaction. Frameworks like Django, ActiveRecord, and Hibernate ship retry middlewares.

When to pick which level

  • READ COMMITTED: 90% of business logic — maximum throughput, fresh snapshot per statement.
  • REPEATABLE READ: when the transaction reads a set of rows multiple times and needs consistency between those reads (e.g., a report that sums fields then cross-checks the aggregate version).
  • SERIALIZABLE: when the business logic has an invariant only preserved by absence of write skew (e.g., “at least one doctor on call”, “no account goes below zero”). Adds up to 5x cost and requires the application to handle retries.

Write skew under Repeatable Read

Write skew is the canonical Repeatable Read anomaly Postgres does not prevent. Two transactions read the same set, decide based on the set, and each modifies a different row in that set. Both commit; the invariant is broken.

Trace it
1/6

Trace a write-skew anomaly under Repeatable Read that Serializable would prevent.

1
Step 1 of 6
Setup: a doctors table with on_call boolean. Two doctors, alice and bob, both on_call=true. The application invariant: at least one doctor must remain on_call at all times.
2
Locked
T1 (alice removing her shift): SELECT COUNT(*) FROM doctors WHERE on_call = true. Returns 2. Decides safe to proceed.
3
Locked
T2 (bob removing his shift, concurrent with T1): same SELECT, same snapshot view. Returns 2.
4
Locked
T1: UPDATE doctors SET on_call = false WHERE name = 'alice'. Commits.
5
Locked
T2: UPDATE doctors SET on_call = false WHERE name = 'bob'. Commits.
6
Locked
Under Serializable, what would have happened?
MVCC + isolation: the numbers that matter
Tuple header size
23 B (plus alignment)
Transaction id width
32 bit; freeze at 2^31
HOT update WAL savings
30–70% on update-heavy workloads
Default isolation level
READ COMMITTED
Serialization failure code
SQLSTATE 40001
Bloat target (>50 GB tables)
<20% dead tuples
Autovacuum defaults
3 workers, 60s naptime, 20% scale
VACUUM FULL lock
ACCESS EXCLUSIVE
Quiz

A HOT (heap-only tuple) update can occur when...

Quiz

At which isolation level does Postgres detect lost-update anomalies and raise SQLSTATE 40001 instead of silently overwriting?

Quiz

Two concurrent transactions each read the on-call doctor count (2), decide they can safely go off-call, and update different rows. Both commit. The invariant breaks. Which isolation level would have prevented this?

Recall before you leave
  1. 01
    What two conditions must both be true for a Postgres UPDATE to use HOT, and what happens when either fails?
  2. 02
    Why is Postgres's Repeatable Read stricter than the SQL standard's Repeatable Read, but weaker than its Serializable?
  3. 03
    What is the correct application response to SQLSTATE 40001 under Repeatable Read or Serializable?
Recap

HOT (heap-only tuple) updates avoid writing new index entries when an UPDATE changes no indexed column and the new tuple fits on the same page — saving 30–70% WAL on update-heavy workloads. The fillfactor storage parameter reserves page headroom to keep HOT chains intact. Isolation levels define correctness: READ COMMITTED (default) re-snapshots per statement and leaves lost updates to the application; REPEATABLE READ (snapshot isolation) raises SQLSTATE 40001 on concurrent row updates but allows write skew; SERIALIZABLE adds SSI to detect write skew cycles, at the cost of predicate-lock overhead and higher retry rates. Applications at any level above RC must implement retry loops that replay the entire transaction on 40001.

Connected lessons
appears again in140
Continue the climb ↑Vacuum and bloat: keeping the storage tax bounded
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.