The Problem MVCC Solves
Any database more than one person uses at once has to satisfy two demands that pull against each other. A reader wants a consistent picture of the data for as long as its query runs. A writer wants to commit now, without waiting for that reader to finish. Multiversion concurrency control satisfies both by keeping several versions of a row alive at the same time and letting each transaction see the version that was current when its snapshot was taken.
Chapter 5 showed where those versions physically sit — the header fields on the page, one tuple per version, the previous one still occupying its bytes long after the update that replaced it. This chapter is about what they mean. The rule the design produces is short enough to memorize: readers never block writers, writers never block readers, and two writers of the same row still block each other. The bill arrives later, as row versions no snapshot can see and something has to remove.
The Alternative Postgres Rejected
The other way to keep a reader consistent is to stop the writers. In a strictly lock-based engine, a SELECT takes a shared lock on what it reads and holds it until its transaction ends, and an UPDATE of the same rows waits for that lock to be released. The correctness argument is easy. The throughput is what suffers, and it suffers exactly where Cartwheel would notice: the analytics dashboard's aggregate over 40 million rows in orders runs for a minute and a half, and under shared read locks every checkout touching those rows queues behind it for that minute and a half.
The escape hatch older engines offered was to let readers see data that had not been committed yet, trading a throughput problem for a correctness one. Postgres offers neither. It accepts READ UNCOMMITTED in the grammar and then runs the transaction as Read Committed, because there is no path through the executor that shows you a row version whose creating transaction has not committed. Four levels are spelled in the standard; three are implemented, and dirty reads are not among the behaviours you can ask for.
SELECT takes a shared lock on what it reads and holds it until its transaction ends. The correctness argument is easy; the throughput is what suffers. The dashboard's ninety-second aggregate over 40 million orders would queue every checkout touching those rows behind it for ninety seconds.UPDATE writes a second, complete version of the row and stamps the first as expired. Nothing is overwritten, so nothing has to be locked against a reader — and dirty reads are not on offer either, whatever the grammar accepts.The Bargain
An UPDATE in Postgres does not find a row and change it. It writes a second, complete version of that row and stamps the first one as expired by the transaction doing the writing. Both versions exist on disk at the same instant, and which one a given query sees is decided entirely by that query's snapshot. Nothing is overwritten, so nothing has to be locked against a reader.
-- session 1: the dashboard -- session 2: checkout BEGIN; SELECT sum(total) FROM orders WHERE placed_at >= '2026-08-12'; -- 90 seconds of scanning UPDATE inventory SET on_hand = on_hand - 1 WHERE product_id = 4471 AND warehouse_id = 2; -- 2 ms, waits for nobody COMMIT; -- still reporting the instant it started from COMMIT;
Neither session pays anything for the other. The checkout does not wait for the aggregate to release a lock, because the aggregate never took one. The aggregate does not see the decrement, because the version the checkout created was born after its snapshot, and its result stays true to a single instant even though the table moved underneath it. That is the whole trade, and it is why Cartwheel can run reporting and checkout against the same rows without a schedule that keeps them apart.
The exception is the one to design around. Two transactions updating the same row do serialize: the second one blocks until the first commits or rolls back, because a row version may only be expired once. It is the one blocking rule in this chapter that MVCC does not remove. On inventory, where 12,000 rows absorb every item in every basket, that queue forms every Saturday morning.
What Each Transaction Sees
A snapshot is taken at a defined moment and decides, for every row version it meets, whether that version is visible. It is the sole arbiter, and there is no second mechanism layered on top of it. That collapses a subject people treat as folklore into two questions: when was the snapshot taken, and what conflicts is the engine watching for while it is held. The three isolation levels differ on those two answers and on nothing else. Both are set per transaction, in one statement.
The Debt: Dead Rows
Every expired version is still on disk, occupying its bytes, until vacuum reclaims the space for that table to reuse. The size of the debt is the update rate multiplied by how long the oldest open transaction has been running, because a version cannot be removed while any snapshot might still need it. On inventory the arithmetic is brutal: 12,000 live rows, several thousand updates a minute, and 900 MB of file behind them.
This is the accounted-for cost of the design, not a defect in it. Postgres makes rollback nearly free at the moment it happens: an aborted transaction leaves its tuples where they are and simply never becomes visible. It pays for that afterwards, in cleanup. Chapter 7 is the whole of the cleanup story; what belongs here is the connection between the two. Engines that keep old versions in an undo log make the opposite trade and settle the bill at rollback time instead.
What MVCC Does Not Give You
MVCC guarantees things about what a statement sees. It guarantees nothing about a decision taken outside the database between two statements. Cartwheel's checkout reads inventory.on_hand, decides in Python whether the sale is allowed, and writes the new value in a second statement — and on a Saturday in March, two sessions ran that sequence at the same second.
-- checkout A -- checkout B BEGIN; BEGIN; SELECT on_hand FROM inventory SELECT on_hand FROM inventory WHERE product_id = 4471 WHERE product_id = 4471 AND warehouse_id = 2; AND warehouse_id = 2; -- 1 -- 1 -- Python: 1 - 1 = 0, allow -- Python: 1 - 1 = 0, allow UPDATE inventory SET on_hand = 0 UPDATE inventory SET on_hand = 0 WHERE product_id = 4471 WHERE product_id = 4471 AND warehouse_id = 2; AND warehouse_id = 2; COMMIT; COMMIT; -- both succeed, one box, two sales
Both snapshots were correct. Neither could show a session the other's uncommitted decrement, because uncommitted versions are invisible by definition — that is the guarantee working, not failing. The second UPDATE waited for the first to commit and then wrote the constant zero it had computed before the wait, because the value came from application memory rather than from the row. This class of failure has a name in the literature, a lost update, and three separate fixes in Postgres. Topic 30 applies all three and says which one Cartwheel ships.
Where the Reader Meets This Again
Almost every operational surprise later in this book is a consequence of the one decision this topic describes. Every non-HOT update writes an index entry too, because an index points at a physical row version rather than at a logical row (Chapter 8). Dead tuples accumulate and autovacuum has to keep up, or a 12,000-row table becomes 900 MB (Chapter 7). Transaction ids are finite and old versions must eventually be frozen, or the cluster stops accepting writes to protect itself. An idle session with an open transaction pins the horizon and stops cleanup cluster-wide, which is this chapter's last topic. And a standby running a long report has to choose between cancelling that report and holding the primary's cleanup back, which is Chapter 13. Every one of them answers to the same two questions: what version is this, and who can still see it.
Postgres MVCC — every version lives in the table's own heap. Rollback costs almost nothing, readers and writers never wait for each other, and cleanup is a permanent operational duty you can measure, tune and get wrong.
Undo-log MVCC (InnoDB, Oracle) — the current row stays in place and previous versions go to an undo area. The table does not accumulate dead rows the same way, rollback is expensive, and a long reader can outrun the retained undo and have its query cancelled instead of holding up cleanup.
Pure locking — keep one version and make readers and writers take turns. Nothing to clean up, no version bookkeeping, and a single long report can stall every writer on the table it touches. Choose it only when the workload is one of read or write, never both at once.
- Reading "readers never block writers" as "concurrent writes are safe" — two transactions updating one row still serialize, and the application logic between a read and a write is exactly where correctness is lost.
- Describing MVCC as a history feature — old versions are garbage awaiting collection, not an audit trail, and once vacuum has run there is nothing left to query.
- Treating bloat as a bug to be avoided by updating less — the cost is designed in, and the answer is per-table autovacuum settings rather than an application that writes less than the business needs.
- Assuming a long report is free because it only reads — it holds a snapshot, and that snapshot stops dead rows being removed on every table in the cluster, not just the ones it touched.
- Expecting
READ UNCOMMITTEDto buy speed by skipping visibility checks — Postgres accepts the syntax and runs Read Committed, so the setting changes nothing at all. - Carrying MySQL operational habits across unchanged — undo-based engines put old versions outside the table, so advice about table growth, rollback cost and long readers does not transfer.
- Say "row version" out loud when reading any
UPDATE, because the vocabulary is the model and it makes vacuum, isolation and index maintenance predictable instead of mysterious. - State what a transaction must see before deciding how to write it — that is the only question isolation levels answer, and answering it first removes most of the argument.
- Keep transactions short by default, and treat every second one stays open as a second of deferred cleanup on every table in the cluster.
- Budget autovacuum capacity for any table with a real update or delete rate, starting with
inventory, before the disk footprint makes the decision for you. - Design around the one blocking rule that remains: two writers of the same row take turns, so hot single rows are a throughput ceiling you can calculate in advance.
Knowledge Check
Under Postgres MVCC, which pair of operations on the same row actually blocks?
- A long SELECT and a concurrent UPDATE of the rows it is scanning
- Two UPDATE statements against the same row from different sessions
- An UPDATE and a SELECT that started before it and is still running
- Two INSERT statements adding entirely different rows to the same table at once
Where does Postgres keep the previous version of a row after an UPDATE, and what follows from that?
- In an undo area outside the table, so the table never accumulates dead rows
- In the write-ahead log, from which readers reconstruct the older version
- In the table itself, which makes rollback cheap and cleanup a standing job
- In a per-table version store that vacuum swaps in when the reader commits
Two checkouts read on_hand as 1, both decide the sale is fine, and both write 0. What does this tell you about MVCC?
- The visibility rules broke down because both sessions ran at the same instant
- It governs what statements see, not decisions made between two statements
- One session performed a dirty read of the other session's uncommitted value
- The row was left in an inconsistent state by two simultaneous writers
What is the operational price Postgres pays for its version-per-row design?
- Rollback becomes expensive because every change must be undone in place
- Dead row versions accumulate in the table until vacuum reclaims the space
- Readers block writers whenever a query runs longer than a few seconds
- A fixed version area that long-running readers can exhaust and overflow
A team sets READ UNCOMMITTED on a reporting connection hoping to skip visibility overhead. What happens?
- The statement is rejected, because Postgres does not accept that level at all
- The session runs as Read Committed, since only three levels are implemented
- The report sees uncommitted changes and runs measurably faster for it
- Visibility checks are skipped entirely, so scans of large tables get cheaper
You got correct