What is MVCC in PostgreSQL, and why don't readers block writers?
MVCC (Multi-Version Concurrency Control) means PostgreSQL keeps several versions of a row. An UPDATE writes a new version instead of overwriting the old one, so a reader keeps seeing the version that was valid when its snapshot was taken while the writer works on the new one. Readers never wait for writers, and writers never wait for readers.
Explain it simply
Think of a college notice board. When the exam timetable changes, the office does not scratch out the old sheet while students are reading it. They pin a new sheet next to it with a note saying "valid from today". A student who started reading the old sheet can finish it. Anyone who walks up later reads the new one. Once nobody is looking at the old sheet any more, a peon takes it down.
PostgreSQL works the same way. An UPDATE does not change a row in place. It writes a new copy of the row and marks the old copy as "replaced by transaction X". Each transaction reads from a snapshot, a picture of which transactions had committed when it started looking. It simply picks the row version that is visible in its snapshot. The cleanup person is VACUUM.
Example
Two sessions work on the same bank account at the same time.
-- Session A
BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE id = 1; -- 2000 becomes 1500
-- not committed yet
-- Session B (runs while A is still open)
SELECT balance FROM accounts WHERE id = 1;
-- Returns 2000 immediately. B does not wait, and it does not see A's uncommitted change.
-- Session A
COMMIT;
-- Session B, new statement
SELECT balance FROM accounts WHERE id = 1;
-- Returns 1500Session B never waited. It read the old version while A was writing the new one.
How it works
Every row version (PostgreSQL calls it a tuple) carries two hidden system columns:
xmin: the ID of the transaction that created this version.xmax: the ID of the transaction that deleted or replaced it (empty if it is still current).
When a query reads a row, PostgreSQL checks xmin and xmax against the query's snapshot. A version is visible if its creator had committed before the snapshot and its deleter had not. An UPDATE is really "mark the old version's xmax, insert a new version". A DELETE only sets xmax.
Because nobody overwrites data in place, a reader never needs a lock on the row. Writers still need locks against other writers: if two transactions update the same row, the second one waits until the first commits or rolls back.
The cost is that old versions pile up as "dead tuples". VACUUM (usually run automatically by autovacuum) later marks that space as reusable.
Real-world use
- A long sales report on a Swiggy-style orders table can run for minutes without freezing order updates, because the report reads from its own snapshot.
pg_dumptakes a consistent backup of a live database for the same reason: it reads one snapshot while the application keeps writing.
Common mistakes
- Saying "PostgreSQL has no locks". It does. Writers lock rows against other writers, and DDL such as
ALTER TABLEtakes heavy table locks. - Thinking an UPDATE changes the row in place. It creates a new version, which is why update-heavy tables grow and need vacuuming.
- Leaving transactions open for hours ("idle in transaction"). VACUUM cannot remove row versions that an old snapshot might still need, so tables bloat.
What to say in the interview
"PostgreSQL uses MVCC. Instead of overwriting a row, an update creates a new row version, and each version records which transaction created it and which one replaced it. Every transaction reads from a snapshot and sees only versions that were committed when the snapshot was taken. So a reader can keep reading the old version while a writer creates a new one, and neither blocks the other. Writers on the same row still wait for each other. The trade-off is dead row versions, which VACUUM cleans up."
Follow-up questions
- What does VACUUM do, and what happens if it never runs?
- When is the snapshot taken under Read Committed versus Repeatable Read?
- Why does a long-running transaction cause table bloat?
Go deeper
Read these once the basics above feel easy.
Under Read Committed (the default) each statement takes a fresh snapshot, so two SELECTs in the same transaction can see different data. Under Repeatable Read the snapshot is taken at the first statement of the transaction and reused until it ends. You can see the hidden columns yourself with SELECT xmin, xmax, * FROM accounts;, which is a good way to watch a row get a new xmin after each update.
When an update does not change any indexed column and the new version fits on the same page, PostgreSQL can do a HOT (heap-only tuple) update: the new version is chained from the old one inside the page and no index entries are added. This keeps indexes from growing on update-heavy tables, and is one reason to set a lower fillfactor on such tables so pages keep free space for new versions.