PostgreSQLVII · MVCC, Transactions and VisibilityMVCC
Why PostgreSQL keeps old row versions
What you'll learn
- Explain why a reader is never blocked by an uncommitted writer
- Read xmin and xmax on a live tuple and say what each records
- Identify what the multi-version design costs, and where that cost is paid
- Explain why a non-zero xmax does not mean a row is dead
Prerequisites
Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27
Everything difficult about operating PostgreSQL — bloat, autovacuum
tuning, wraparound, long transactions, replication lag from
hot_standby_feedback — descends from one design decision made
decades ago and never reversed.
PostgreSQL does not modify rows. It writes new versions of them.
This lesson establishes what that means concretely, because the rest of Parts VII, VIII and IX are consequences of it.
Two sessions, one row
The demonstration is short and the result is the whole point. Session A opens a transaction, updates a row, and does not commit.
$ psql -U postgres -c "BEGIN" -c "SELECT pg_backend_pid(), pg_current_xact_id()" -c "UPDATE mvcc SET balance = 999 WHERE id = 1" session_a_pid | session_a_xid
---------------+---------------
1605 | 683645
who | id | balance
---------+----+---------
A sees: | 1 | 999
A sees: | 2 | 200While A holds that transaction open, session B reads the same row.
$ psql -U postgres -c "SELECT 'B sees:' AS who, id, balance FROM mvcc ORDER BY id" who | id | balance
---------+----+---------
B sees: | 1 | 100
B sees: | 2 | 200Two sessions, two different answers, both correct, neither waiting. On a database that updates rows in place, B has three options: read the uncommitted value and be wrong, wait for A, or read from a separate copy of the old value that the system maintained on the side. PostgreSQL takes a fourth: the old value is still there, in the table, as a row of its own.
What the row headers show
The mechanism is visible in the tuple headers from Part VI.
$ psql -U postgres -c "SELECT id, balance, xmin, xmax, ctid FROM mvcc ORDER BY id" id | balance | xmin | xmax | ctid
----+---------+--------+--------+-------
1 | 100 | 683644 | 683645 | (0,1)
2 | 200 | 683644 | 0 | (0,2)
(2 rows)Row 1 already carries xmax = 683645 — session A’s transaction id —
even though A has not committed. That is not a bug and not a
half-applied change.
The rule is simple once stated: a version’s headers record which
transactions bracket its life, and whether those transactions committed
is looked up separately, at read time. B sees xmax = 683645, checks
the status of transaction 683645, finds it still in progress, and
concludes that this version has not yet been superseded as far as B is
concerned.
| Field | Records |
|---|---|
xmin | The transaction that created this version |
xmax | The transaction that deleted, superseded or locked it |
ctid | This version’s physical address |
What this buys
Readers never block writers, and writers never block readers. No
read lock is taken on ordinary SELECT, so a long reporting query
cannot stall a write workload and a write workload cannot stall the
report. This is the property that makes PostgreSQL comfortable with
mixed workloads that would deadlock elsewhere.
A consistent view without freezing the database. Every statement, or every transaction depending on isolation level, works from a fixed point in time. The next lesson but one covers how that point is represented.
Rollback is nearly free. Aborting does not undo anything: the versions the transaction wrote are simply never visible to anyone, because the transaction’s status says aborted. A transaction that modified ten million rows and rolls back does so in about the time it takes to write one status record.
What it costs
Dead versions accumulate. Every update and every delete leaves something behind. Nothing removes it as part of the operation itself.
Something has to reclaim them. That is vacuum, and Part VIII is entirely about it. This is the source of most PostgreSQL operational work.
Reclamation is blocked by anything that might still need the old version. A transaction that opened a snapshot an hour ago may still read a version deleted fifty-nine minutes ago, so that version cannot be removed. Lesson VII-05 measures what this costs; the short version is that one idle session turned a 1.7 MB table into a 19 MB one in this course’s own test cluster.
Indexes point at versions, not at rows. A new version generally needs a new entry in every index on the table. Lesson VII-02 measures that too, and it is expensive.
What to take from this
- An
UPDATEwrites a new version and stamps the old one. Nothing is overwritten. - Visibility is decided by looking up transaction status at read time, not by locking.
xmaxrecords deletion, supersession or locking, so it is not a test for a dead row.- Rollback is cheap; cleaning up after it is not, and happens later.
- Every vacuum topic in this course is downstream of this one decision.
Cross-course references
- Linux for Production Sysadmins — Part XLI (Storage Performance) covers measuring the write amplification old row versions cause.
- Observability for Production Sysadmins — Part LIX (Database observability) covers exporting dead-tuple counts as a series rather than reading them once.
Quiz
Knowledge check · 6 questions
Q1. Session A has an uncommitted UPDATE on row 1. Session B runs SELECT on the same row and gets the old value immediately. What made that possible?
Q2. A data migration updates ten million rows, runs for two hours, and is then cancelled. The operator reports that it rolled back instantly with no impact. What is wrong with that assessment?
Q3. A colleague writes a monitoring query that counts rows WHERE xmax <> 0 and reports the result as the number of dead tuples. On a busy table the number is much higher than pg_stat_user_tables reports. Why?
Q4. Which of these are direct consequences of storing old row versions in the table itself? Select all that apply.
Q5. In PostgreSQL, whether a row version is visible to a transaction is determined by looking up the status of the transactions recorded in its header, rather than by taking locks.
Q6. Explain why advice about long-running reporting queries does not transfer between PostgreSQL and an undo-based database such as Oracle or InnoDB.
Passing score: 75%. Answers are checked in this browser.