MVCC in PostgreSQL: xmin, xmax and snapshots Intermediate
Estimated reading time: 7 min read
The problem
Section titled “The problem”One request changes a balance from 100 to 70. At the same time, another request opens a page showing the balance.
If there were only one memory cell for this number, the reader would have to choose: wait for the writer to finish, or risk seeing 70 even if the writer rolls back.
PostgreSQL takes a third path: MVCC. Several versions of the same row coexist for a while; each reader picks the version valid for its read point. The consequence: a plain SELECT does not wait for writers, but every UPDATE leaves garbage for VACUUM to clean up.
The model: versions, snapshots, visibility
Section titled “The model: versions, snapshots, visibility”- Each
UPDATEcreates a new tuple in the heap instead of overwriting the old one. Its fixed header (about 23 bytes) holdsxmin(XID of the transaction that created the version),xmax(XID of the transaction that deleted, updated or locked it), andt_ctidpointing to the newer version. - A snapshot answers “at read time, which transactions had committed and which were still running?”. It is not a copy of the table.
- A tuple’s visibility is the result of checking
xmin/xmaxagainst the snapshot (and the commit status of those XIDs).
A heap page after one UPDATE (illustrative XIDs)
Section titled “A heap page after one UPDATE (illustrative XIDs)”Heap page of table accounts, row id = 1 slot 1 v1 balance = 100 xmin = 100 xmax = 205 t_ctid -> slot 2 slot 2 v2 balance = 70 xmin = 205 xmax = 0 t_ctid -> slot 2 (transaction 205 is the one running the UPDATE)Who sees which version:
| Reader | State of tx 205 | Sees | Reason |
|---|---|---|---|
| Tx 205 itself | running | v2 = 70 | A transaction always sees its own changes |
| Snapshot taken before 205 commits | not committed | v1 = 100 | xmin = 100 is committed; xmax = 205 is not committed, so it has no effect yet |
| Snapshot taken after 205 commits | committed | v2 = 70 | xmax = 205 is committed, so v1 counts as replaced; xmin = 205 is committed, so v2 is valid |
| Anyone, if 205 rolls back | aborted | v1 = 100 | xmax = 205 from an aborted transaction is ignored; v2 never appears |
The simplified rule (enough to reason with, skipping edge cases like subtransactions): a version is visible when xmin is committed per the snapshot (or is your own transaction) and xmax is 0, not yet committed, or aborted.
How it works
Section titled “How it works”v1 = 100Initially the row has a committed version v1 = 100. The reader reads v1.
- Initially there is only v1 = 100, committed.
UPDATEcreates v2 = 70. v1 is not physically deleted; the writer sees v2.- Before
COMMIT, plain readers still pick v1, so they never see uncommitted data. - After
COMMIT, aSELECTstarting afterwards under Read Committed sees 70. ASELECTthat started earlier, or a Repeatable Read transaction holding an old snapshot, still sees 100. - v1 is only reclaimed once no snapshot needs it anymore. A long-open reading transaction blocks this, even if it only reads.
| Point | Writer sees | New SELECT under Read Committed |
Repeatable Read reader (snapshot taken before the UPDATE) |
|---|---|---|---|
| Before UPDATE | 100 | 100 | 100 |
| UPDATE done, not committed | 70 | 100 | 100 |
| After COMMIT | 70 | 70 | 100 |
“Committed but I still see the old value” is usually about snapshots, not replication lag: check the isolation level and when the snapshot was taken first. A Repeatable Read snapshot is taken at the first statement that is not a transaction-control command, not at the word BEGIN.
MVCC does not mean no locks
Section titled “MVCC does not mean no locks”- Two writers modifying the same row still wait for each other (see row-level locks).
SELECT ... FOR UPDATEwrites to the row to mark it locked, so it causes disk writes even though you are only “reading” (docs). Using it on many hot rows dirties pages and increases WAL.- This is why a non-zero
xmaxdoes not mean the row was deleted: it may record a row lock or belong to a rolled-back transaction. Do not write business logic onxmin/xmax; they are not modification times and not an audit log.
Why PostgreSQL tables bloat
Section titled “Why PostgreSQL tables bloat”Old versions live right in the heap, alongside live data. Some other engines (InnoDB, Oracle) keep old versions in a separate undo area; that architectural difference explains why write-heavy PostgreSQL tables need frequent VACUUM. The concrete costs and tuning are in VACUUM, autovacuum and bloat.
Real-world example
Section titled “Real-world example”Single-session demo (TEMP table, leaves nothing behind):
docker compose -f examples/docker-compose.yml up -d postgresdocker compose -f examples/docker-compose.yml exec -T postgres psql -U hub -d hub < examples/src/postgres-mvcc/mvcc-demo.sqlThe script prints balance, xmin, xmax and ctid before the UPDATE, inside the transaction, after rollback and after commit. XIDs and ctid change every run; just check the sequence 100, 70 inside the transaction, 100 after rollback, 70 after commit.
To see two snapshots at once, use a regular table (not TEMP, since each session has its own temp tables):
-- ACREATE TABLE mvcc_shared (id int PRIMARY KEY, balance int);INSERT INTO mvcc_shared VALUES (1, 100);BEGIN; UPDATE mvcc_shared SET balance = 70 WHERE id = 1; -- stop here, no COMMIT yet
-- BSELECT balance FROM mvcc_shared WHERE id = 1; -- 100
-- ACOMMIT;-- BSELECT balance FROM mvcc_shared WHERE id = 1; -- 70 (Read Committed)DROP TABLE mvcc_shared;Trade-offs
Section titled “Trade-offs”What MVCC gives you
- Plain readers read the committed version while a writer has not committed: reads do not block writes, writes do not block reads.
- A snapshot describes exactly what data a statement or transaction sees.
- A rollback never forces any reader to correct results already returned.
Costs and limits
UPDATE/DELETEcreate versions that need cleanup: VACUUM and bloat are a permanent cost.- Long-held snapshots slow down reclamation of old versions.
- MVCC does not replace locks for two writers on the same row, and does not prevent write skew under Repeatable Read.
Common mistakes
Section titled “Common mistakes”Code review checklist
Section titled “Code review checklist”- No business logic reads
xmin/xmaxas a modification time, deletion flag or audit log. - Transactions (even read-only ones) are not left open for long; reports use Repeatable Read with
statement_timeout. - Seeing “committed but still reading the old value”: check the isolation level and snapshot timing before suspecting replication lag.
- Several queries needing one point in time: wrap them in
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY, rather than stitching results from several Read Committed snapshots. - Do not sprinkle
SELECT ... FOR UPDATEover many hot rows just to “read safely”: it writes to the rows, dirties pages and increases WAL.
Summary
Section titled “Summary”Self-check: (1) A writer updates 100 to 70 but has not committed: what do the writer and a plain reader see, and what is v1’s xmax?
(2) Why can a Repeatable Read reader still see 100 after the writer commits? (3) Why does a read-only transaction left open for a long time still bloat the table?