Two 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.
Multi-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.
What changed in 2026
- 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.
- Long-running analytical queries against transactional databases became more common, which is precisely the pattern that pins old versions and starves cleanup.
- 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.
- Observability improved. Bloat and oldest-transaction-age moved into standard dashboards rather than being something you queried manually during an incident.
How the snapshot works
Every 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.
When 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.
The consequences follow directly:
A read never waits. It reads whichever version its snapshot says it should see. There is no lock to acquire.
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.
A delete does not free space immediately. It marks the version as deleted. The bytes remain until nobody can see them.
That 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.
Dead versions and cleanup
Once 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.
When 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.
The most common cause is not a slow cleanup process. It is a transaction that will not end.
The long transaction problem
Cleanup 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.
The usual culprits are undramatic:
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.
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 and columnar storage.
An abandoned session. A connection that died without the database noticing, leaving a transaction open indefinitely.
The 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.
Monitoring the age of the oldest open transaction is one of the highest-value database alerts available, and one of the least commonly configured.
Common mistakes
- Assuming DELETE frees space. It marks versions dead; cleanup frees them, later.
- Holding a transaction open across an external call. The dominant cause of bloat in application-driven systems.
- 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.
- Not monitoring oldest transaction age. The leading indicator for the whole class of problem.
- Running long analytics against the primary. Legitimate queries with an illegitimate side effect.
- Bulk-updating a large table in one transaction. Creates a version per row and pins them all until commit. Batch it.
- Ignoring index bloat. Indexes bloat alongside tables and are often the larger share.
FAQ
Do all databases use MVCC?
Most 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.
Is bloat the same as fragmentation?
Related 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 covers the index side.
Does MVCC prevent concurrency bugs?
No, 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.
Why did my table get bigger after deleting rows?
Expected. 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.
Where to go next
For the anomalies snapshots do and do not prevent, read database isolation levels. For the index-side cost of version churn, index bloat, and for the durability mechanism alongside it, write-ahead logging.