Lesson 6 · Internals & data structures
MVCC
Multi-Version Concurrency Control — the idea most Postgres interviews orbit.
Your win: explain how Postgres lets many transactions read and write concurrently without blocking each other — and name the one consequence (dead tuples) that sets up VACUUM.
The problem, and Postgres's answer
Many transactions hit the same rows at once. A naive database locks a row while it's being written, blocking readers. Postgres instead uses MVCC: writers create new versions rather than overwriting, so readers can keep seeing an older consistent version.1
UPDATE or DELETE never overwrites a tuple.
It writes a new tuple version and marks the old one dead. Each tuple carries
xmin (the transaction that created it) and xmax (the
transaction that deleted/superseded it).
Snapshots: what a transaction sees
Each transaction (or statement, at READ COMMITTED) works against a
snapshot — the set of transactions committed at that instant. A
tuple is visible if its xmin committed before the snapshot and its
xmax hasn't. So two transactions can see different versions of the
same row at the same time, each internally consistent.
Your code is doing MVCC constantly
UPDATE emails SET deleted_at = now() WHERE … — is MVCC
in action: it writes a new tuple for that email_id and marks the
old one dead; the row isn't physically removed (which is also why your reads filter
deleted_at IS NULL). And database.ExecInTxWithRetry retries on
SerializationFailure (SQLSTATE 40001) — a conflict that only
arises under stricter isolation built on MVCC (Part 5).
PostgreSQL — Concurrency Control (MVCC) + Rogov Internals Part I
The official MVCC chapter, then Rogov's deep, diagram-led treatment of tuples, snapshots, and visibility.
→ postgresql.org/docs — MVCC
→ PostgreSQL 14 Internals — Part I (Isolation & MVCC)
Check yourself (from memory)
Q1. In MVCC, an UPDATE…
xmax). Never an in-place overwrite.
Q2. MVCC's key benefit is that…
Q3. Each transaction reads from…
xmin = creating txid,
xmax = deleting txid), and every transaction reads from a consistent
SNAPSHOT of which transactions had committed. So readers never block writers and writers
never block readers (same-row writers still serialize) — concurrency without read locks.
Consequence: dead tuple versions pile up and must be reclaimed by VACUUM. Our soft-delete
UPDATE … SET deleted_at = now() creates a new version every time.xmin/xmax live (SELECT xmin, xmax, * FROM …),
or how this becomes the isolation levels of Part 5? Ask me.
1. PostgreSQL — Concurrency Control; The Internals of PostgreSQL — Concurrency Control.