MVCC - How PostgreSQL Does Concurrency
UPDATE never overwrites a row. It creates a new version. Readers use snapshots. Dead versions pile up until VACUUM reclaims them - and restores Heap Fetches: 0.
10 min read
This video presents visual lesson highlights with instrumental background music. The complete lesson is available as text below.
Day 10 taught you ACID isolation as a product promise. Day 20 taught you that VACUUM unlocks “Heap Fetches: 0.” Today: the mechanism underneath both - Multi-Version Concurrency Control. UPDATE never overwrites a row. It creates a new version. Readers see a snapshot. Dead versions pile up until VACUUM reclaims them.
Day 10’s isolation levels answer what you see under concurrency. Day 20’s visibility map answers whether an index-only scan can skip the heap. Both depend on the same engine: MVCC - Multi-Version Concurrency Control. Understanding MVCC turns “VACUUM matters” from a tip into a causal chain you can reason about under load.
The problem: readers and writers want the same row
Imagine a product page: one process updates price, a thousand processes read it. If the database used a single lock per row for every access, every read would queue for every write. That is how some older systems behaved - and how PostgreSQL behaves only when you ask for a lock with SELECT … FOR UPDATE.
The product requirement is different:
- Writers must not block readers. A long
UPDATEshould not stall everySELECT. - Readers must not block writers. A long report query should not freeze checkouts.
- Each transaction needs a consistent picture of the data - Day 10’s isolation promise.
MVCC solves this by storing multiple versions of each row. A reader never waits for a writer’s new version - it reads the version that was committed when its snapshot started.
The mechanism: UPDATE creates a new row version
In PostgreSQL, an UPDATE does not overwrite bytes in place. It:
- Marks the old row version as expired (sets
xmax= the updating transaction’s ID). - Inserts a new row version with the new values (
xmin= that same transaction ID). - Leaves the old version on disk until no snapshot still needs it.
-- Conceptual view of one logical row after UPDATE
-- version 1: price=10.00 xmin=100 xmax=200 ← dead after txn 200 commits
-- version 2: price=12.00 xmin=200 xmax=null ← live
UPDATE products SET price = 12.00 WHERE id = 42;
xmin / xmax are system columns. Every row version carries the transaction IDs that created and (optionally) deleted it. A snapshot decides: “is this version visible to me?”
Snapshots: isolation without waiting
Under Read Committed (PostgreSQL’s default - Day 10), each statement gets a fresh snapshot of committed data. Under Repeatable Read / Serializable, the snapshot is fixed for the whole transaction.
Visibility rule (simplified): a version is visible if its creating transaction committed before your snapshot, and it has not been deleted by a transaction that committed before your snapshot. You never see uncommitted data (no dirty reads in PostgreSQL - even “Read Uncommitted” behaves like Read Committed).
-- Session A -- Session B
BEGIN; BEGIN;
UPDATE products -- still sees old price
SET price = 12 WHERE id = 42; SELECT price FROM products WHERE id = 42;
-- not committed yet -- → 10.00 (A's uncommitted version invisible)
COMMIT; SELECT price FROM products WHERE id = 42;
-- → 12.00 (new statement, new snapshot)
That is Day 10’s isolation made physical: not magic locks on every read - versioned rows + snapshot filtering.
Readers don’t block writers - proof with FOR UPDATE
MVCC’s non-blocking property is about plain reads. The moment you take a row lock, you wait like everyone else.
Real check on PostgreSQL 18.4 (Podman til-postgres): while transaction A held SELECT … FOR UPDATE on a row:
- Transaction B’s plain
SELECTsucceeded immediately - no lock wait (MVCC snapshot of the last committed version). - Transaction B’s
SELECT … FOR UPDATEwithlock_timeout = 500mstimed out - lock wait, not MVCC.
-- A holds the row lock
BEGIN;
SELECT * FROM lock_demo WHERE id = 1 FOR UPDATE;
-- B can still read the committed version
SELECT * FROM lock_demo WHERE id = 1; -- succeeds (MVCC)
-- B cannot take the same exclusive lock
SET lock_timeout = '500ms';
SELECT * FROM lock_demo WHERE id = 1 FOR UPDATE;
-- ERROR: canceling statement due to lock timeout
Interview phrasing: “PostgreSQL readers don’t block writers and writers don’t block readers - unless the reader asks for a lock.”
The cost: dead tuples and bloat
Every UPDATE and DELETE leaves dead versions. They still occupy pages. Indexes still point at them until cleaned. That is table bloat.
Real benchmark: 200,000-row table, mass UPDATE of every row (30-run medians, PostgreSQL 18.4):
- After UPDATE: 200,000 dead tuples, table 14 MB → 29 MB (doubled).
- After ordinary
VACUUM: dead count → 0, but size stays 29 MB (pages free for reuse, file not shrunk). - After
VACUUM FULL: size back to 14 MB - exclusive lock, full rewrite. Use sparingly.
This is why autovacuum exists. Without it, every hot table grows a graveyard of versions and every scan pays for corpses it must skip.
VACUUM + visibility map = Day 20’s “Heap Fetches: 0”
Day 20: covering indexes enable Index Only Scans only when the visibility map says a page is all-visible. VACUUM sets those bits. Dirty pages after UPDATE force heap checks even when the index has every column.
Same 200K table, covering index on (status, created_at DESC) INCLUDE (id, total), query WHERE status = ‘pending’ … LIMIT 100, 30-run median:
| Condition | Median latency | Heap Fetches | Buffers (sample) |
|---|---|---|---|
| Bloated (post-UPDATE) | 0.227 ms | 100 | shared hit=206 |
| After VACUUM | 0.155 ms | 0 | shared hit=6 |
-- Bloated (visibility map dirty)
Index Only Scan using idx_mvcc_status_created
Heap Fetches: 100 -- must verify each row in the heap
Buffers: shared hit=206
-- After VACUUM
Index Only Scan using idx_mvcc_status_created
Heap Fetches: 0 -- true index-only
Buffers: shared hit=6
1.46× faster on this warm-buffer micro-query - but the durable claim is the causal chain: MVCC versions → dirty visibility map → forced heap fetches → VACUUM restores index-only scans. Absolute sub-ms numbers are machine-local; Heap Fetches 100→0 is the proof.
What breaks? - Anti-patterns
Long-running transactions that never commit. VACUUM cannot remove row versions still visible to an open snapshot. One abandoned
BEGINin a pool connection freezes cleanup for the whole database. Fix: setidle_in_transaction_session_timeout; monitorpg_stat_activity.Assuming VACUUM shrinks the file. Ordinary VACUUM reclaims for reuse. Disk usage stays high until
VACUUM FULLor a rewrite (CLUSTER, dump/restore). Fix: measure bloat; use FULL only offline / carefully.Disabling autovacuum “for performance.” You trade short-term I/O for unbounded bloat and broken index-only scans. Fix: tune autovacuum thresholds; never turn it off globally.
Using FOR UPDATE for “consistency” on every read path. You throw away MVCC’s non-blocking property and reintroduce reader/writer queues. Fix: reserve row locks for true critical sections (Day 10 lost-update patterns).
How it connects
- Day 10 (ACID): Isolation levels are snapshot policies on top of MVCC versions.
- Day 20 (covering indexes): Visibility map is MVCC’s “is this page safe to trust from the index alone?” bitset - maintained by VACUUM.
- Day 22 (partial indexes): Fewer indexed rows still create dead index entries on UPDATE/DELETE of matching rows - VACUUM cleans indexes too.
- Day 23 (replication): Followers replay WAL that encodes these version changes. Hot standbys also run VACUUM-related cleanup under constraints - lag and long queries interact with cleanup.
- Day 8 (pooling): Leaked transactions in a pool are the #1 real-world MVCC footgun.
Transfer questions
- Your covering index shows
Heap Fetches: 5000after a batch job. What did the batch do to the visibility map, and what operation restores true index-only scans? - A dashboard query is “read only” but uses
SELECT … FOR UPDATE“just in case.” How does that change concurrency behavior under MVCC? - After a huge
UPDATE,n_dead_tupis zero post-VACUUM butpg_relation_sizeis still doubled. Is that a bug? What command would shrink the file, and what is the tradeoff?
What you should be able to do
- Explain that PostgreSQL UPDATE/DELETE create new versions instead of overwriting in place.
- Connect snapshots to isolation (Day 10) without claiming readers always take locks.
- State why plain SELECT does not wait on FOR UPDATE holders, but another FOR UPDATE does.
- Read
n_dead_tup/ table size / Heap Fetches as MVCC health signals. - Distinguish VACUUM (mark reusable + fix visibility map) from VACUUM FULL (rewrite + reclaim disk).
1. What does a normal heap UPDATE do to a row in PostgreSQL?
2. Session A holds SELECT … FOR UPDATE on a row. Session B runs a plain SELECT on the same row. What happens?
3. After a mass UPDATE, VACUUM sets n_dead_tup to 0 but table size stays doubled. What is true?
Your teach step
Close this lesson. Write the “Explain like I’m 10” and the “60-second LinkedIn version” from memory. Focus on: UPDATE creates versions, readers use snapshots, dead tuples need VACUUM, and why Day 20’s Heap Fetches depend on that cleanup. Post it, and paste the link.
Questions? Ask the agent - unclear snapshots, autovacuum tuning, or how this shows up in EXPLAIN are all fair game.