SD System Design In Depth
0%
lesson system-designrelational-databasestransactions

MVCC

Module 2.1: Relational Modeling & Query Execution Updated Sep 17, 2026
📄 Cheat Sheet
PostgreSQL Internals in Action: MVCC
Denis Magda PostgreSQL Internals in Action: MVCC Watch on YouTube ↗

Live demo inspecting xmin/xmax and tuple versions while concurrent transactions run: exactly the tuple-header mechanics this lesson describes.

0:00 / --:--

Under the Hood: Multi-Version Concurrency Control (MVCC)

In older database engines, readers blocked writers and writers blocked readers using shared and exclusive page locks. Multi-Version Concurrency Control (MVCC) eliminates read-write lock contention by ensuring that readers never block writers, and writers never block readers. (Writers updating the same row still block each other.)

PostgreSQL MVCC Tuple Header Anatomy

flowchart LR subgraph TupleHeader ["Physical Heap Tuple Header (23 Bytes)"] XMIN["xmin (32-bit: Creating Transaction ID)"] XMAX["xmax (32-bit: Deleting/Updating Transaction ID)"] CID["t_cid (Command ID in Transaction)"] TUP["Tuple Data Payload"] end

How Mutations Work in MVCC

1. Updating a Row (No In-Place Overwrites in PostgreSQL)

This append-in-heap design is PostgreSQL's. InnoDB and Oracle instead update rows in place and keep old versions in undo logs (see the table below).

When you execute UPDATE users SET email = '[email protected]' WHERE id = 1;, PostgreSQL does not overwrite the existing bytes on disk:

  1. It records the current transaction ID into the old tuple's xmax field, marking it dead to future transactions.
  2. It writes an entirely new tuple onto a disk page with xmin = current_tx_id and xmax = 0.
  3. The old tuple remains on disk so active concurrent read transactions can continue reading the old version consistent with their snapshot.

2. The Vacuum Requirement (Dead Tuple Bloat)

Because updates and deletes create dead tuples on disk, disk space bloats over time. The VACUUM daemon scans table pages, checks if any active transaction can still observe old versions, and marks the space of dead tuples as reusable within each page. Plain VACUUM returns space to the operating system only by truncating empty pages at the end of the table; shrinking a bloated table needs VACUUM FULL or pg_repack.

Worked example: two snapshots, one row

Row users id=1 is inserted by transaction 100, so it has xmin=100 and xmax=0.

StepActionHeap tuples for id=1
1T200 begins (Repeatable Read) and takes snapshot: sees commits up to 199v1 (xmin 100, xmax 0)
2T201 runs UPDATE users SET email = 'b' WHERE id = 1v1 (xmin 100, xmax 201), v2 (xmin 201, xmax 0)
3T201 commitsunchanged; commit status recorded in the commit log (pg_xact)
4T200 reads id=1v1 is visible (201 is not in its snapshot) and v2 is invisible, so it sees the old email
5New T202 reads id=1v1 is dead (xmax 201 committed) and v2 is visible, so it sees 'b'
6T200 ends, then VACUUM runsv1 is no longer visible to anyone and its space is reclaimed

Where the old versions live

EngineOld versions stored inConsequence
PostgreSQLThe table heap itself (new tuple per update)Table and index bloat; needs VACUUM; HOT updates avoid index writes when no indexed column changes
MySQL InnoDBUndo log (row updated in place, previous image in undo)Readers rebuild old versions by walking undo; a long transaction makes the history list grow
OracleUndo tablespace"Snapshot too old" error if undo is overwritten before a long query finishes
CockroachDBMultiple timestamped keys in the LSMGarbage collected after gc.ttlseconds (default 4 hours in current versions)

Numbers that matter

  • An UPDATE-heavy table doing 1,000 updates/s produces 86M dead tuples per day. Default autovacuum triggers at 20% dead rows, which is too lazy for large hot tables. Set per-table scale factors near 0.01-0.05.
  • XID wraparound: about 2 billion transactions of headroom. At 20k transactions/s that is roughly 28 hours if freezing stalls completely, so monitor age(datfrozenxid).

Failure modes

  • Idle in transaction: set idle_in_transaction_session_timeout.
  • Replica feedback: hot_standby_feedback on replicas also holds back vacuum on the primary.
  • Write-write conflicts: at Repeatable Read, updating a row changed after your snapshot raises "could not serialize access due to concurrent update", so retry.
Check yourself Question 1 of 3
What UPDATE really writes recall
From: How Mutations Work in MVCC

In PostgreSQL, what physically happens when a transaction runs UPDATE users SET email = '[email protected]' WHERE id = 1?

MVCC and Isolation Levels (PostgreSQL)

Every UPDATE writes a new row version stamped with the writer's xid (xmin) and marks the old one dead (xmax). A read returns the version its snapshot can see. Pick an anomaly and an isolation level, then step through the script.

Row versions in the heap
What each level does (each script run to the end)

Key Takeaways

MVCC ensures readers never block writers and writers never block readers by maintaining multiple tuple versions.
In PostgreSQL, updates never modify data in place; they set xmax on the old tuple and insert a new tuple with xmin (InnoDB and Oracle update in place and keep old versions in undo).
Dead tuples accumulate on disk until VACUUM marks their space reusable (VACUUM FULL or pg_repack is needed to shrink the file).
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue