{"post":{"slug":"mvcc-explained-2026","title":"MVCC Explained: How Readers Stop Blocking Writers","excerpt":"Your database serves a consistent snapshot to every transaction without locking anything. The trick is keeping several versions of every row, and it has a cost.","category":"code","date":"August 14, 2026","author":"ByteLedger Team","content":"<span class=\"dropcap\">T</span>wo things want the same row at the same moment. One is a reporting query that will run for thirty seconds. The other is a user updating their profile. In a naive design, one waits for the other — either the report blocks the write, or the write blocks the report, and somebody has a bad time.\n\nMulti-version concurrency control makes both proceed. The report sees the row as it existed when the report started; the update creates a new version alongside the old one. Neither waits. This is why \"readers don't block writers, writers don't block readers\" is a thing databases advertise, and it is genuinely excellent — with a bill attached that arrives later.\n\n## What changed in 2026\n\n- **Autovacuum tuning got easier and no less necessary.** Better defaults and adaptive behaviour reduced the manual work; the failure mode when it falls behind is unchanged.\n- **Long-running analytical queries against transactional databases became more common**, which is precisely the pattern that pins old versions and starves cleanup.\n- **Connection pooling at the edge made idle transactions more likely.** A pooled connection left mid-transaction by an application bug now has more ways to happen.\n- **Observability improved.** Bloat and oldest-transaction-age moved into standard dashboards rather than being something you queried manually during an incident.\n\n## How the snapshot works\n\nEvery row version carries metadata about which transaction created it and which, if any, deleted it. Every transaction gets a snapshot: a record of which transactions had committed at the moment it started.\n\nWhen a transaction reads a row, the database walks the versions and shows the one that was visible according to that snapshot. Versions created by transactions that had not committed yet are invisible. Versions deleted by later transactions are still visible if the deletion had not committed when the snapshot was taken.\n\nThe consequences follow directly:\n\n**A read never waits.** It reads whichever version its snapshot says it should see. There is no lock to acquire.\n\n**An update never blocks a read.** It writes a new version and marks the old one as deleted by this transaction. The old version stays put for anyone whose snapshot still needs it.\n\n**A delete does not free space immediately.** It marks the version as deleted. The bytes remain until nobody can see them.\n\nThat last point is the whole cost of the design. Deleting a million rows makes the table no smaller. It makes it slightly larger, because the deletion metadata has to go somewhere.\n\n## Dead versions and cleanup\n\nOnce no active transaction can see a version, it is dead and its space is reclaimable. A background process finds and reclaims it — `VACUUM` in Postgres, purge threads elsewhere.\n\nWhen that process keeps up, everything is fine and you never think about it. When it falls behind, you get **bloat**: a table occupying far more space than its live rows justify. Bloat is not just wasted disk. Every sequential scan reads the dead space, indexes grow to cover versions that no longer exist, and the buffer cache fills with rows nobody can see. Queries get slower for no reason visible in the query itself.\n\nThe most common cause is not a slow cleanup process. It is a transaction that will not end.\n\n## The long transaction problem\n\nCleanup can only reclaim versions no active transaction might need. One transaction that started an hour ago and is still open pins every version created since it began — across the entire database, not just tables it touched.\n\nThe usual culprits are undramatic:\n\n**An idle-in-transaction connection.** An application opened a transaction, did a query, and then did something slow — an API call, a file write, waiting on user input — without committing. The transaction is doing nothing and holding everything.\n\n**A genuinely long analytical query.** Legitimate, and still pinning versions for its duration. This is the argument for running analytics against a replica or a separate system — see [read replicas explained](/blog/read-replicas-explained-2026) and [columnar storage](/blog/columnar-storage-explained-2026).\n\n**An abandoned session.** A connection that died without the database noticing, leaving a transaction open indefinitely.\n\nThe rule that prevents nearly all of it: never hold a transaction open across a network call or any operation whose duration you do not control. Read what you need, commit, then do the slow work. If you must write afterwards, open a new transaction and handle the possibility that things changed.\n\nMonitoring the age of the oldest open transaction is one of the highest-value database alerts available, and one of the least commonly configured.\n\n## Common mistakes\n\n- **Assuming DELETE frees space.** It marks versions dead; cleanup frees them, later.\n- **Holding a transaction open across an external call.** The dominant cause of bloat in application-driven systems.\n- **Disabling autovacuum because it caused I/O spikes.** Tune it to run more often and more gently; disabled, the problem compounds until it becomes an emergency.\n- **Not monitoring oldest transaction age.** The leading indicator for the whole class of problem.\n- **Running long analytics against the primary.** Legitimate queries with an illegitimate side effect.\n- **Bulk-updating a large table in one transaction.** Creates a version per row and pins them all until commit. Batch it.\n- **Ignoring index bloat.** Indexes bloat alongside tables and are often the larger share.\n\n## FAQ\n\n**Do all databases use MVCC?**\nMost mainstream ones use it or something close, though the implementations differ substantially in where old versions live. Some keep them in the table itself; others keep them in a separate undo area, which changes the cleanup characteristics considerably. Check what yours does before generalising advice between engines.\n\n**Is bloat the same as fragmentation?**\nRelated and not identical. Bloat is space held by dead versions. Fragmentation is poor physical arrangement of live data. Cleanup addresses bloat; reorganising addresses fragmentation, and heavy bloat tends to produce fragmentation too. [Index bloat](/blog/index-bloat-explained-2026) covers the index side.\n\n**Does MVCC prevent concurrency bugs?**\nNo, and this is worth being clear about. It gives you consistent snapshots; it does not stop two transactions from reading the same value and both writing back an update. That is an isolation-level question — see [database isolation levels](/blog/database-isolation-levels-2026).\n\n**Why did my table get bigger after deleting rows?**\nExpected. The deletion added metadata and freed nothing until cleanup ran. If it stays large afterwards, cleanup is being blocked, most likely by a long transaction.\n\n## Where to go next\n\nFor the anomalies snapshots do and do not prevent, read [database isolation levels](/blog/database-isolation-levels-2026). For the index-side cost of version churn, [index bloat](/blog/index-bloat-explained-2026), and for the durability mechanism alongside it, [write-ahead logging](/blog/write-ahead-logging-explained-2026).","hue":301,"metaTitle":"MVCC Explained: Readers That Never Block Writers","metaDescription":"How multi-version concurrency control gives every transaction a consistent snapshot, why dead row versions cause bloat, and how long transactions break cleanup.","keywords":["mvcc","multiversion concurrency control","postgres vacuum","table bloat","snapshot isolation","long running transaction","database concurrency","row versioning 2026"],"tldr":["**Writers create new row versions instead of overwriting** — so readers see a consistent snapshot without ever waiting for a lock.","**Dead versions accumulate and must be cleaned up** — that background reclamation is where MVCC's operational cost lives.","**A long-running transaction pins old versions** — one idle transaction can block cleanup across the entire database.","**Table bloat is the visible symptom** — a table far larger than its live rows means cleanup is losing a race it should be winning.","**Skip:** leaving a transaction open while you do something slow. Read the data, commit, then do the slow work."],"readTime":"5 min read","wordCount":1096},"related":[{"slug":"database-transactions-explained-2026","title":"Database transactions explained in 2026: ACID, isolation, and real examples","excerpt":"Transactions are the mechanism that keeps your database consistent when multiple operations run concurrently. This is the 2026 guide to ACID properties, isolation levels, and the patterns that prevent data corruption.","category":"code","date":"April 13, 2026","author":"ByteLedger Team","readTime":"6 min read","hue":318},{"slug":"transactions-explained-2026","title":"Database transactions explained in 2026: ACID, isolation, and beyond","excerpt":"Transactions are the safety net of every database write. This 2026 guide covers ACID, isolation levels, deadlocks, and the patterns that prevent data corruption.","category":"code","date":"April 21, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":310},{"slug":"autovacuum-tuning-2026","title":"Autovacuum Tuning: Keeping Cleanup Ahead of Churn","excerpt":"The background process reclaiming dead rows is fine until it is not. When it falls behind on a hot table, bloat grows, queries slow, and nothing in your application changed.","category":"code","date":"January 6, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":310}],"sameCategory":[{"slug":"bazel-build-explained-2026","title":"Bazel Explained: What Hermetic Builds Actually Cost","excerpt":"Bazel delivers reproducible, precisely cached builds and demands that you describe everything. Here is what you get, what it costs, and who should say no.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":145},{"slug":"graphql-federation-2026","title":"GraphQL Federation: One Graph, Many Teams","excerpt":"Federation composes independently-owned subgraphs into a single API. Here is what it solves, the operational cost it adds, and when a simpler gateway is enough.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":300},{"slug":"hermetic-builds-explained-2026","title":"Hermetic Builds Explained: Same Inputs, Same Output","excerpt":"A hermetic build depends only on declared inputs. Here is what that property gives you, the common leaks that break it, and how to move toward it incrementally.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":130},{"slug":"monorepo-tooling-compared-2026","title":"Monorepo Tooling Compared: What You Actually Need","excerpt":"Monorepo tools solve task orchestration and caching, not repository layout. Here is what each tier provides and how to tell when you have outgrown the simple option.","category":"code","date":"August 19, 2026","author":"ByteLedger Team","readTime":"3 min read","hue":150}],"newer":{"slug":"llm-guardrail-testing-2026","title":"LLM Guardrail Testing: Proving the Limits Actually Hold","excerpt":"A guardrail you have not attacked is a guardrail you are guessing about. Here is how to build an adversarial test suite and what to measure beyond pass rate.","category":"ai","date":"August 14, 2026","author":"ByteLedger Team","readTime":"4 min read","hue":219},"older":{"slug":"notification-audit-guide-2026","title":"The Notification Audit: Turning Off What Interrupts You","excerpt":"Every app ships with notifications enabled because that serves the app. An hour spent turning most of them off is the highest-return focus change available.","category":"guides","date":"August 14, 2026","author":"ByteLedger Team","readTime":"5 min read","hue":156}}