Topic 28

xmin, xmax, and a Row's Lifetime

Row Versions

Every row version carries the id of the transaction that created it and the id of the transaction that expired it. Every transaction that writes anything is handed a monotonically increasing 32-bit id from a cluster-wide counter. A commit log on disk records which of those ids committed and which aborted. Those three facts are the entire machinery of MVCC.

Chapter 5 laid the header fields out as bytes on a page. What makes this topic worth an hour at a real terminal is that the same fields are selectable from SQL, in any session, without an extension. Two psql windows and one UPDATE are enough to watch a row version be born and another be marked for death, and the mental model that comes from seeing it does not go away.

Transaction Ids, and Who Gets One

An id is not allocated when a transaction starts. It is allocated the first time the transaction writes. Until then the transaction is identified by a virtual id built from the backend's process number and a counter local to that backend, printed as 4/12532 in pg_locks, and it consumes nothing that the rest of the cluster has to account for. This is not a micro-optimization. It is the reason the analytics dashboard on pg-replica-a, which runs thousands of aggregates a day and writes nothing, advances the cluster's counter by exactly zero.

A transaction gets a real id only when it writes
BEGIN;
SELECT pg_current_xact_id_if_assigned();   -- NULL: nothing written yet

SELECT count(*) FROM inventory;            -- still nothing written
SELECT pg_current_xact_id_if_assigned();   -- still NULL

UPDATE inventory SET on_hand = on_hand
 WHERE product_id = 4471;                  -- now it writes
SELECT pg_current_xact_id_if_assigned();   -- 487355026
COMMIT;

The two functions differ in exactly that respect: pg_current_xact_id() assigns an id on the spot if the transaction does not have one, while pg_current_xact_id_if_assigned() reports the truth and returns null when there is nothing to report. Reach for the second one when investigating, because the first changes the thing you are measuring. Both return the 64-bit xid8 form that carries the wraparound epoch alongside the 32-bit value stored in the row.

Reading the Fields From SQL

Every table has system columns that do not appear in SELECT * but can be named explicitly. Three of them matter here: xmin, the transaction that created this version; xmax, the transaction that expired it, or zero while it is live; and ctid, the physical address of the version as a page number and slot. Query them alongside the ordinary columns and MVCC stops being an argument about semantics.

Two sessions, one row, and the fields changing under an UPDATE
-- session 1
SELECT xmin, xmax, ctid, on_hand FROM inventory
 WHERE product_id = 4471 AND warehouse_id = 2;
--   xmin    | xmax |  ctid   | on_hand
-- 487355012 |    0 | (17,3)  |      12

-- session 2
BEGIN;
UPDATE inventory SET on_hand = on_hand - 1
 WHERE product_id = 4471 AND warehouse_id = 2;
-- not committed yet

-- session 1 again: unchanged, because session 2 has not committed
-- 487355012 |    0 | (17,3)  |      12

-- session 2 commits, then session 1 re-queries
--   xmin    | xmax |  ctid   | on_hand
-- 487355035 |    0 | (17,48) |      11

The row the second query returns is a different physical object. Its xmin is the updating transaction's id, its ctid points at a different slot, and its on_hand is one lower. The version that used to answer this query is still on the page at (17,3) with its xmax now set to 487355035, invisible to any snapshot taken after that commit and waiting for vacuum. Nothing was modified in place; a second row was written and the first was retired.

One reading habit is worth forming immediately. A non-zero xmax does not mean "deleted". It means some transaction claimed this version — which includes a transaction that expired it and then rolled back, and includes a plain SELECT … FOR UPDATE that only locked it. Which of the three happened is recorded in pg_xact, not in the row.

A non-zero xmax means somebody claimed this version — not that it is gone
The transaction expired it, and committedThe version is dead
The transaction expired it, and rolled backThe version is still live
A plain SELECT … FOR UPDATE only locked itThe version is still live
Where the answer actually livesNot in the row

The Commit Log and Hint Bits

An xmin of 487355035 tells you which transaction wrote the version. It does not tell you whether that transaction committed, and the tuple has no room for an answer that was not known when it was written. The answer lives in the commit log under pg_xact in the data directory, two bits per transaction, and every visibility check that meets an unresolved id has to go and look. pg_xact_status() exposes the same lookup from SQL, returning in progress, committed or aborted for an id recent enough to still be recorded.

Going to the commit log for every row of every scan would be intolerable, so the first reader that resolves an id writes the answer back into the tuple's info-mask — the hint bits Chapter 5 read off the page, and the reason a plain SELECT produces write I/O on a table nobody has modified. The half that belongs here is what the arrangement does to the price of a visibility check. A tuple that has already been hinted is decided from bytes the scan is holding anyway. One that has not costs a lookup in pg_xact, which is a memory read for a recent id and a disk read for an old one, so two scans of the same rows can differ by an order of magnitude with nothing about the query or the data having changed.

The life of one row version
Bornxmin = the writing transaction, xmax = 0
Claimedxmax stamped, a new version written
Resolvedthe commit log says committed or aborted
Hintedthe first reader writes the answer into the tuple
Reclaimedvacuum takes the bytes back

Rollback Leaves Rows Behind

A rolled-back INSERT still wrote its tuples. Nothing goes back to erase them: the commit log records the abort, every visibility check that meets those rows sees a creating transaction that failed, and the rows are skipped. They occupy their bytes until vacuum removes them like any other dead version.

This is why an aborted 4,000-row warehouse batch costs disk even though it changed nothing anyone can see, and why ROLLBACK on a transaction that touched a million rows returns instantly instead of grinding through an undo log. Postgres makes rollback nearly free at the moment it happens and settles up later, which is the exact inverse of the engines this book keeps comparing it to. Those 4,000 tuples stay where they were written until a vacuum reaches them.

Subtransactions and Savepoints

SAVEPOINT starts a subtransaction, and so does every PL/pgSQL block with an EXCEPTION clause, whether or not an exception is ever raised. A subtransaction that only reads gets a virtual id; one that writes is assigned its own id, called a subxid, and the parent's id is always lower than any of its children's. The outcome of the child is recorded separately, which is what lets the parent survive a failed statement.

The cost is real and sharp-edged: up to 64 open subxids are cached in shared memory per backend, and past that point every visibility check on those rows has to consult pg_subtrans on disk, with the I/O that implies. A loader that wraps each row of a 4,000-row batch in its own exception handler creates 4,000 open subtransactions in one transaction and falls off that cliff sixty-four rows in. The symptom looks like a locking problem: high latency, backends waiting, no obvious lock. Handling errors at the batch level and re-running the whole batch keeps the open subxid count at one.

A Counter That Wraps

The id stored in a row version is 32 bits, and the counter wraps every four billion transactions. Comparison is modulo arithmetic rather than absolute: at any moment roughly two billion ids count as being in the past and two billion in the future, which means an id left untouched for long enough would eventually appear to be in the future and its rows would vanish from every snapshot. The 64-bit xid8 type used by the SQL-level functions carries an epoch alongside the 32-bit value and does not wrap during the life of an installation, but the bytes on the page are still 32 bits.

That is not an abstraction leak to shrug at. It is the reason freezing exists, the reason vacuum has a second job unrelated to space, and the reason Chapter 7 ends with a cluster refusing writes to protect itself. Only transactions that write consume ids, so the arithmetic that matters for Cartwheel is the checkout rate and the ingest rate, not the dashboard's query count. A reporting replica running flat out moves the cluster no closer to the boundary.

Common Mistakes
  • Storing xmin as a row version number or a proxy for "when was this written" — the counter wraps, freezing rewrites the value, and it carries no wall-clock meaning at any point.
  • Reading a non-zero xmax as proof the row was deleted — a lock or an aborted update sets it too, so the field means "claimed", and the commit log holds the outcome.
  • Putting an EXCEPTION block inside a per-row loop in PL/pgSQL — past 64 open subxids per backend the visibility checks start reading pg_subtrans, and throughput collapses in a way that looks like contention.
  • Calling pg_current_xact_id() from a monitoring query — it assigns an id to a transaction that had not written one, so the act of measuring is what makes read-only sessions consume the counter.
  • Treating a rolled-back batch as free — its tuples were written before the abort, and they occupy their bytes until a vacuum reaches them, so the disk moved even though the data did not.
  • Assuming read-only reporting transactions bring wraparound closer — they take virtual ids and consume none of the counter, so the risk is entirely a function of the write rate.
Best Practices
  • Run the two-session xmin/xmax/ctid demonstration on a scratch table once, and keep the transcript — it settles more arguments than any diagram.
  • Use pg_current_xact_id_if_assigned() when investigating, since the plain form allocates an id and changes what you are measuring.
  • Handle errors at the batch level in stored procedures and loaders, and retry the batch, rather than wrapping every row in its own exception block.
  • Settle a doubtful row's fate with pg_xact_status() on its xmax rather than inferring it from the field, since the row records the claim and the commit log records the outcome.
  • Reason about wraparound headroom from the rate of writing transactions per day, and check it against the counter rather than against row counts.
Comparable toolsInnoDB DB_TRX_ID and DB_ROLL_PTR per row, pointing into undoOracle SCNs plus undo segments, with rollback done by replaySQL Server version pointers into a tempdb version storepageinspect the same fields read straight off the page

Knowledge Check

A reporting session runs for an hour on pg-replica-a and writes nothing. What does it do to the cluster's transaction id counter?

  • Nothing, because a read-only transaction is given only a virtual id
  • It advances the shared counter once per statement the session executes
  • It takes one id at BEGIN, as every transaction does when it starts
  • It advances the replica's own counter, which diverges from the primary's

A row version has a non-zero xmax. What can you conclude?

  • The row was deleted by a committed transaction and is waiting for vacuum to reclaim it
  • Some transaction claimed the version, and the outcome is recorded elsewhere
  • The transaction that expired the version has definitely committed already
  • The field holds the physical address of the newer version of the row

A row version's xmax names transaction 487355035. Where is it recorded whether that transaction committed?

  • In the tuple's info-mask, filled in when the version was written
  • In the WAL, which a visibility check replays to reach the outcome
  • In the commit log under pg_xact, which pg_xact_status() reads
  • In pg_stat_activity, for as long as that backend stays connected

A PL/pgSQL loader wraps each row of a 4,000-row batch in its own EXCEPTION block. What breaks?

  • The lock table fills up, because each subtransaction takes its own lock slot
  • Past 64 open subtransactions, visibility checks start going to disk
  • Each subtransaction takes a fresh snapshot, so the loop slows down linearly
  • Rows written before a failure are silently committed by the enclosing block

A migration inserts 900,000 rows and then rolls back. What is the state of the table?

  • Unchanged, since the rollback removed the inserted tuples as it unwound
  • The tuples are on disk, permanently invisible, and waiting for vacuum
  • Unchanged, but the rollback itself took as long as the insert did
  • The rows are marked with xmax by a cleanup pass at the end of the rollback

You got correct