Hook: Interviewers love MVCC because it explains why a busy database can still feel fast.
Question: What is MVCC in SQL databases?
Answer: MVCC stands for Multi-Version Concurrency Control. It is a way for a database to keep more than one version of a row so readers can see a stable snapshot while writers make changes. That means a query usually does not block another query just because a row is being updated. The main trade-off is extra storage and cleanup work for old row versions.
Interview-Ready Answer: “MVCC means the database keeps multiple row versions instead of overwriting data in place. When I run a query, I see a snapshot of rows that were committed before my transaction started, so readers do not block writers in the common case. Updates create a new version, and old versions stay around until no active transaction can see them. That is why MVCC improves concurrency, but it also creates dead rows that vacuum or undo cleanup has to remove.”
MVCC = Multi-Version Concurrency Control. Instead of overwriting a row in place, the database keeps row versions. A transaction sees a snapshot, meaning the set of committed row versions it is allowed to read at that moment. That is why a long query is not surprised by half-finished writes.
SELECT, the engine finds candidate rows and checks visibility. A row version is visible if its creating transaction committed before your snapshot and its deleting or updating transaction is not visible to that snapshot.UPDATE, the database usually creates a brand-new row version. The old version is marked as no longer current, but it is not physically erased right away.DELETE, the row is also just marked dead for future snapshots. Your current query stops seeing it, but older snapshots may still see it until they finish.VACUUM in PostgreSQL removes dead versions only after no active transaction can possibly need them. This is why long-running transactions are dangerous.MVCC is used because it makes read-heavy systems feel fast: readers usually do not block writers, and writers usually do not block readers. That is perfect for checkout pages, feeds, dashboards, and APIs where many small reads happen all the time.
| Approach | Reader behavior | Writer behavior | Main trade-off |
|---|---|---|---|
| Locking | Waits | Holds lock | Simple, but blocks |
| MVCC | Reads snapshot | Writes new version | Fast reads, cleanup cost |
20% dead tuples plus a small threshold of 50 rows by default, so bloat can grow if the table is large or updates are constant.Memory model: think “readers get yesterday’s newspaper, writers print today’s edition, and vacuum recycles the old papers later.”
Imagine a checkout service for a shopping app. Hundreds of readers check item price and stock while orders constantly decrement inventory. MVCC lets those reads keep moving without waiting for each update, so the page stays responsive during a flash sale.
What can go wrong: a reporting job opens a transaction, runs a few SELECTs, then sits idle for 45 minutes because the app forgot to COMMIT. That one old snapshot pins dead row versions, autovacuum cannot clean them, table files grow, and query latency climbs from tens of milliseconds to hundreds.
Symptoms: disk usage spikes, autovacuum messages mention old snapshots or skipped cleanup, and the app starts logging slow queries. User impact: stock checks slow down, checkout may stall, and eventually the database can even run out of disk space.
-- PostgreSQL MVCC demo: create row versions, show visibility metadata, and show a failure path
-- This script is intentionally simple so you can run it in psql or any PostgreSQL client.
DROP TABLE IF EXISTS mvcc_demo;
CREATE TABLE mvcc_demo (
id integer PRIMARY KEY,
balance integer NOT NULL CHECK (balance >= 0)
);
-- Insert two rows. Each row version gets its own creating transaction ID.
INSERT INTO mvcc_demo (id, balance) VALUES (1, 100), (2, 250);
-- UPDATE does not overwrite the row in place; PostgreSQL creates a new row version.
UPDATE mvcc_demo
SET balance = balance + 50
WHERE id = 1;
-- These system columns are PostgreSQL-specific, but they make MVCC visible.
-- xmin = transaction that created the visible version
-- xmax = transaction that deleted or replaced the version, if any
SELECT id,
balance,
xmin::text AS creating_txn,
xmax::text AS deleting_txn,
ctid
FROM mvcc_demo
ORDER BY id;
-- DELETE makes the row invisible to future snapshots.
DELETE FROM mvcc_demo
WHERE id = 2;
-- Failure path / edge case: the row is logically gone, so a later update by key affects 0 rows.
WITH attempted_update AS (
UPDATE mvcc_demo
SET balance = balance + 10
WHERE id = 2
RETURNING 1
)
SELECT COUNT(*) AS rows_updated_after_delete
FROM attempted_update;
-- Reinsert the same key: this creates a fresh row version, not the old deleted one.
INSERT INTO mvcc_demo (id, balance) VALUES (2, 300);
-- VACUUM can reclaim dead row versions only when no active transaction still needs them.
-- Do not wrap this whole script in an explicit transaction block if your client forbids VACUUM there.
VACUUM mvcc_demo;
SELECT id,
balance,
xmin::text AS creating_txn,
xmax::text AS deleting_txn,
ctid
FROM mvcc_demo
ORDER BY id;Follow-up & Tricky Questions:
SELECT ... FOR UPDATE also take locks on purpose.Common Mistakes:
DELETE immediately frees disk space. Correction: The row becomes invisible first; physical space is reclaimed later by cleanup.Memory Hook: “MVCC is a newspaper stand: readers take a copy of today’s paper, writers print a new edition, and the recycler removes old copies later.”
Cheat Sheet:
Practice Tasks:
xmin, xmax, and ctid columns after each change.READ COMMITTED versus REPEATABLE READ on the same row.