PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 21: MVCC

MVCC: tuple versions, snapshots and HOT

Level 5: Internals30 min

PostgreSQL never overwrites a row in place. Every UPDATE writes a new version and every DELETE marks the old one, and each transaction sees the versions that were committed when its snapshot was taken. Readers never block writers and writers never block readers.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Explain xmin, xmax and ctid, and how a snapshot decides visibility
  • See dead tuples appear after UPDATE and DELETE with pageinspect
  • Explain HOT updates and fillfactor

conceptThe concept

Every tuple carries xmin (the transaction that created it) and xmax (the transaction that deleted or replaced it, or 0). An UPDATE sets xmax on the old version and inserts a new version with a new ctid (page, slot) location. A snapshot records which transactions were committed at a point in time; a tuple is visible if its xmin committed before the snapshot and its xmax did not.

Old versions remain until no snapshot can need them, then they are dead, and VACUUM reclaims the space. This is the price of MVCC: updates create garbage, and a long-running transaction keeps garbage alive for the whole database.

A HOT (heap-only tuple) update happens when no indexed column changes and the new version fits on the same page. The indexes are not touched; the old version points to the new one. HOT is much cheaper, and a lower fillfactor (for example 80) leaves room on each page to make it likely.

Open the interactive lesson to use this diagram.

whyWhy it matters

MVCC explains table bloat, why VACUUM exists, why a forgotten "idle in transaction" session can degrade a whole server, and why updating an indexed column costs more than updating a non-indexed one.

Use caseReal-world scenario

A session counter table updated 1,000 times per second grows without bound. Removing an index on the updated column and setting fillfactor to 70 turns every update into a HOT update and keeps the table small.

Try itExamples

Run and edit these in the interactive lesson.

System columns on every row
SELECT xmin, xmax, ctid, id, name FROM employees ORDER BY id LIMIT 4;
SELECT pg_current_xact_id() AS my_xid, pg_current_snapshot() AS snapshot;
Watch an UPDATE create a new version
DROP TABLE IF EXISTS mvcc_lab;
CREATE TABLE mvcc_lab (id int PRIMARY KEY, val int) WITH (autovacuum_enabled = off);
INSERT INTO mvcc_lab VALUES (1, 10), (2, 20);
UPDATE mvcc_lab SET val = val + 1 WHERE id = 1;
UPDATE mvcc_lab SET val = val + 1 WHERE id = 1;
DELETE FROM mvcc_lab WHERE id = 2;
SELECT lp, t_xmin, t_xmax, t_ctid,
       (t_infomask2 & 16384) > 0 AS hot_updated,
       (t_infomask2 & 32768) > 0 AS heap_only
FROM heap_page_items(get_raw_page('mvcc_lab', 0));
SELECT ctid, * FROM mvcc_lab;
DROP TABLE mvcc_lab;
Five tuples on the page for one visible row: the two old versions of id 1 have xmax set, id 2 is deleted, and the chain is HOT because val is not indexed.
HOT versus non-HOT updates
DROP TABLE IF EXISTS hot_lab;
CREATE TABLE hot_lab (id int PRIMARY KEY, indexed_col int, plain_col int) WITH (fillfactor = 50, autovacuum_enabled = off);
CREATE INDEX ON hot_lab (indexed_col);
INSERT INTO hot_lab SELECT g, g, g FROM generate_series(1, 1000) g;
UPDATE hot_lab SET plain_col = plain_col + 1;
SELECT pg_stat_force_next_flush();
SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 'hot_lab';
UPDATE hot_lab SET indexed_col = indexed_col + 1;
SELECT pg_stat_force_next_flush();
SELECT n_tup_upd, n_tup_hot_upd FROM pg_stat_user_tables WHERE relname = 'hot_lab';
DROP TABLE hot_lab;
All 1,000 updates of plain_col were HOT because each page had half its space free. None of the 1,000 updates of the indexed column could be. Try fillfactor = 70: only about 45% of the plain updates stay HOT, because pages fill up and new versions spill to other pages.
Your own transaction sees its own changes
BEGIN;
UPDATE products SET stock = 0 WHERE id = 1;
SELECT xmin, xmax, stock, pg_current_xact_id() FROM products WHERE id = 1;
ROLLBACK;
SELECT xmin, stock FROM products WHERE id = 1;
Inside the transaction, the new version carries your xid as xmin. After ROLLBACK that xid is marked aborted, so the new version is invisible to everyone and the old one is current again.

internalsUnder the hood

Transaction IDs are 32-bit counters. Commit status lives in pg_xact (the CLOG), with hint bits cached on tuples after the first check. pg_current_snapshot() shows xmin, xmax and the in-progress list. n_tup_hot_upd in pg_stat_user_tables counts HOT updates.

Avoid / preferDo this, not that

Avoid
UPDATE products SET active = true;  -- most rows already active
Prefer
UPDATE products SET active = true WHERE active IS DISTINCT FROM true;

Every updated row gets a new version even if nothing changed, creating dead tuples and WAL for nothing.

Avoid
-- application opens a transaction, then waits for user input
Prefer
SET idle_in_transaction_session_timeout = '5min';
-- and keep transactions short

An open transaction holds back the xmin horizon, so VACUUM cannot remove any dead tuple created after it started, in any table.

PitfallsCommon mistakes

  • Leaving sessions "idle in transaction". Their snapshot pins every dead tuple created since, across all tables, so VACUUM cannot clean anything.
  • Indexing frequently updated columns that are rarely queried. It disables HOT for those updates and multiplies write I/O.
  • Expecting DELETE to free disk space. It creates dead tuples; VACUUM makes the space reusable, and only VACUUM FULL or pg_repack returns it to the OS.

LimitsWhen not to use it

MVCC is always on; the choice is your update pattern. Very frequent updates of wide rows (counters, last_seen timestamps) are better in a narrow separate table or an append-only design.

SecuritySecurity notes

Deleted and updated rows stay in data files until VACUUM reclaims and later writes overwrite them, and they remain in WAL archives and backups. Erasure requirements must account for backups and retention, not just DELETE.

ChallengeTry it yourself

Show that an UPDATE gives a row a new physical location (ctid) and stamps it with your transaction id (xmin).

Show one solution
One solution
BEGIN;
SELECT ctid FROM products WHERE id = 1;
UPDATE products SET stock = stock + 1 WHERE id = 1;
SELECT ctid, xmin::text::bigint = txid_current() AS written_by_me FROM products WHERE id = 1;
ROLLBACK;

InterviewQuestions you should be able to answer

How does MVCC work in PostgreSQL?
Each row version stores xmin and xmax transaction ids. UPDATE writes a new version and sets xmax on the old one; DELETE sets xmax. Each statement (READ COMMITTED) or transaction (REPEATABLE READ and above) takes a snapshot listing which transactions are committed, and a version is visible if xmin committed before the snapshot and xmax did not. Readers never block writers. The cost is dead tuples that VACUUM must reclaim, and long transactions holding back cleanup.
What is a HOT update and how do you encourage it?
A heap-only tuple update: if no indexed column changes and the new version fits on the same page, PostgreSQL writes it on that page and links it from the old version without inserting new index entries. Index entries keep pointing at the chain head. Encourage it by not indexing frequently updated columns and lowering fillfactor so pages keep free space. Track it with n_tup_hot_upd in pg_stat_user_tables.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation