Versions instead of locks
Multi-version concurrency control removes the conflict by refusing to destroy anything. A write does not overwrite a row; it creates a *new version* of it, and the old version stays until nobody can still need it.
Every transaction is given a snapshot — effectively a rule saying which versions count as visible — fixed at the moment it starts, or at the moment each statement starts, depending on the [isolation level](isolation_levels.html).
A reader that began before a write simply continues to see the older version. Nothing waits.
What each version carries
Conceptually each row version records which transaction created it and which transaction deleted it:
id | balance | created_by | deleted_by
----+---------+------------+-----------
7 | 1000 | 100 | 142
7 | 850 | 142 | NULL
Transaction 142 changed the balance. It did not edit the first line; it marked it deleted and appended the second. A transaction with a snapshot older than 142 is shown the first version, a newer one the second. Visibility becomes an arithmetic comparison of transaction ids rather than a queue.
What MVCC does not solve
This is the part that gets missed. MVCC eliminates reader/writer conflicts. It does not eliminate writer/writer conflicts.
Two transactions updating the same row still contend: the second must wait for the first to commit or roll back, because they would otherwise both produce a new version from the same old one and one update would vanish. That is the [lost update](isolation_levels.html) problem, and MVCC's answer to it is either a lock on that row or a serialisation failure at commit.
So the rule is: readers never wait, writers to the same row still do.
The cost: garbage
Old versions accumulate. Something has to decide when a version can no longer be seen by any live snapshot and reclaim the space.
In PostgreSQL that job is VACUUM, usually run by autovacuum. When it cannot keep up — typically because a very old transaction is still open and pinning every version created since — tables bloat: the row count is unchanged while the file grows, and every scan reads more pages for the same data.
The practical consequence is worth stating plainly: a transaction left open does damage even when it is doing nothing, because it holds back cleanup for the whole database. An idle-in-transaction connection is a bug, not a small inefficiency.
Different engines pay this differently. PostgreSQL keeps old versions in the table itself and vacuums them. MySQL's InnoDB keeps them in a separate undo log and purges it. Oracle uses undo segments, and the same open-transaction problem appears as ORA-01555 snapshot too old — the old version needed was already discarded.
Where it goes wrong
Long-running transactions. They pin versions across the entire database. Keep them short, and never leave one open across a user interaction.
Assuming no waiting at all. Writers to the same row still contend, and the error surfaces as a lock wait or a serialisation failure.
Treating table bloat as a disk problem. It is a symptom of vacuum not keeping up, and adding disk hides it rather than fixing it.
Counting rows to check for bloat. The row count is right; the file size is the thing that grew.