Skip to main content
RunBook Academy

PostgreSQLVII · MVCC, Transactions and VisibilityMVCC

Why PostgreSQL keeps old row versions

Intermediate⏱ ~25 minpsql

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

Not yet marked complete on this device.

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.

Read-only / Safesession A: update, hold, do 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 |     200

While A holds that transaction open, session B reads the same row.

Read-only / Safesession B, concurrently
$ 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 |     200

Two 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.

Read-only / Safethe same rows, with their headers, read by B
$ 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.

FieldRecords
xminThe transaction that created this version
xmaxThe transaction that deleted, superseded or locked it
ctidThis 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 UPDATE writes 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.
  • xmax records 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

  1. 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?

  2. 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?

  3. 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?

  4. Q4. Which of these are direct consequences of storing old row versions in the table itself? Select all that apply.

  5. 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.

  6. 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.