What is MVCC and how does it allow readers and writers not to block each other?
Updated Feb 20, 2026
Short answer
Multi-Version Concurrency Control keeps multiple versions of each row, so a writer creates a new version instead of overwriting the old one. Readers continue seeing the version that was current when their transaction or statement began, which means reads never block writes and writes never block reads. The cost is storage for dead versions and the background work to reclaim them.
Deep explanation
Without MVCC, a reader must take a shared lock to guarantee a stable view, and a writer needs an exclusive lock — so one blocks the other. MVCC removes the conflict by never destroying data in place.
In Postgres each row version carries xmin (the transaction that created it) and xmax (the transaction that deleted or superseded it). A transaction sees a version if xmin is committed and visible to its snapshot, and xmax is not.
UPDATE accounts SET balance = 900 WHERE id = 1;
before: (id=1, balance=1000, xmin=100, xmax=null)after: (id=1, balance=1000, xmin=100, xmax=205) <- old version, now dead (id=1, balance=900, xmin=205, xmax=null) <- new versionA transaction that started before 205 committed still reads 1000; one starting after reads 900. Neither waits.
What still blocks. Two concurrent writers to the same row. MVCC solves reader-writer contention, not writer-writer — the second writer waits for the first to commit or roll back.
The cost is garbage. Dead versions accumulate and must be reclaimed: Postgres calls it VACUUM, MySQL's InnoDB keeps old versions in the undo log, Oracle in undo tablespaces. If reclamation falls behind — usually because a long-running transaction holds a snapshot open and pins every version newer than it — tables bloat, indexes grow, and performance degrades. That is why an idle-in-transaction connection is a genuine operational hazard rather than a curiosity.
Isolation levels sit on top. Read Committed takes a fresh snapshot per statement, so a repeated query can see new data. Repeatable Read takes one snapshot per transaction, giving a consistent view throughout. Serializable adds conflict detection on top, which can abort a transaction that would otherwise produce a non-serialisable outcome.
Real-world example
A long analytics query running while transactions continue:
-- session A, 09:00, takes ~10 minutesbegin isolation level repeatable read;select sum(balance) from accounts; -- scans 40 million rows
-- session B, 09:03 — does NOT wait for Aupdate accounts set balance = balance - 100 where id = 77;commit;Session A's total reflects the state at 09:00 throughout, and session B commits immediately. Under lock-based concurrency, B would block for ten minutes or A would read inconsistent data mid-scan.
The hazard is the mirror image: if session A ran for six hours, every row version superseded during those six hours must be retained, and vacuum cannot reclaim any of it.
Common mistakes
- - Assuming MVCC eliminates all blocking
- concurrent writers to the same row still serialise.
- - Leaving transactions open — an idle-in-transaction session pins a snapshot and prevents cleanup of every version created since.
- - Treating table bloat as a disk problem rather than a symptom of vacuum falling behind long-running transactions.
- - Expecting Read Committed to give a stable view within a transaction
- it re-snapshots per statement, so the same query can return different results.
- - Believing Repeatable Read prevents all anomalies — write skew is still possible and requires Serializable.
- - Running large batch updates without considering that each updated row creates a new version, doubling the table's physical size before cleanup.
Follow-up questions
- Why does a long-running transaction cause table bloat?
- What is the difference between Read Committed and Repeatable Read under MVCC?
- How do MySQL and Postgres differ in storing old versions?
- What is write skew and why doesn't Repeatable Read prevent it?