Skip to content

Snapshots under the hood

Chapter 2 treated the snapshot as a black box: "a frozen view of committed data". Here's the box, opened. A snapshot is three numbers, and every visibility decision (every row you do or don't see) is arithmetic on them plus the xmin/xmax stamps from the previous lesson.

componentmeaning
xminthe oldest xid still running when the snapshot was taken, everything below is settled (committed or rolled back)
xmaxone past the highest completed xid, everything at or above hadn't finished, so it's invisible
xipthe xids between the two that were still in progress at snapshot time: invisible, forever, in this snapshot

A row version is visible if its xmin is committed, below xmax, and not in xip, and its xmax (if set) fails that same test. Notice what's absent from the rule: the current time. Whether the creator commits a nanosecond or an hour after the snapshot was taken changes nothing.

The three numbers, live

Transaction ids are handed out lazily — a transaction that only reads never gets one.

A> BEGIN;
BEGIN

A> SELECT balance FROM accounts WHERE id = 1;
 balance 
---------
     100 
(1 row)

A> SELECT pg_current_xact_id_if_assigned() AS xid;
 xid 
-----
     
(1 row)

A> UPDATE accounts SET balance = 150 WHERE id = 1;
UPDATE 1

A> SELECT pg_current_xact_id_if_assigned() AS xid;
 xid  
------
 1001 
(1 row)

B grabs the next xid and commits immediately. A is still in progress.

B> UPDATE accounts SET balance = 200 WHERE id = 2 RETURNING xmin, balance;
 xmin | balance 
------+---------
 1002 |     200 
(1 row)

C opens a transaction and inspects its own snapshot: the three numbers that decide all visibility.

C> BEGIN ISOLATION LEVEL REPEATABLE READ;
BEGIN

C> SELECT pg_snapshot_xmin(pg_current_snapshot()) AS xmin,
          pg_snapshot_xmax(pg_current_snapshot()) AS xmax;
 xmin | xmax 
------+------
 1001 | 1003 
(1 row)

xip = the xids that were in progress at snapshot time. A is on the list — so A is invisible even after it commits.

C> SELECT pg_snapshot_xip(pg_current_snapshot()) AS xid;
 xid  
------
 1001 
(1 row)

The arithmetic in action: B (committed, below xmax) is visible; A (in xip) is not.

C> SELECT id, owner, balance FROM accounts ORDER BY id;
 id | owner | balance 
----+-------+---------
  1 | alice |     100 
  2 | bob   |     200 
(2 rows)

A> COMMIT;
COMMIT

A has now committed — but C's snapshot already decided: still invisible.

C> SELECT id, owner, balance FROM accounts ORDER BY id;
 id | owner | balance 
----+-------+---------
  1 | alice |     100 
  2 | bob   |     200 
(2 rows)

C> COMMIT;
COMMIT

Only a NEW snapshot changes the verdict.

C> SELECT id, owner, balance FROM accounts ORDER BY id;
 id | owner | balance 
----+-------+---------
  1 | alice |     150 
  2 | bob   |     200 
(2 rows)

Verified against PostgreSQL 18.4 · Run it yourself · Scenario source

Three beats reward a second look. The first is that xids are handed out lazily: A's transaction had no xid until its first write, because assignment "happens when a transaction first writes to the database". Read-only transactions never consume one, which is why monitoring columns like backend_xid are so often null for busy read-only sessions, and why the workloads that burn through xids are the write-heavy ones.

The second: xmax is one past the highest completed xid, not the next id to be assigned. A got its xid before B, yet sits at xmin, below xmax, alive in xip: the three numbers bracket exactly the region where "did this commit?" is still ambiguous.

The third is the punchline: commit doesn't matter, because the snapshot already decided. A commits mid-scenario and C's verdict doesn't budge. That reframes the whole of chapter 2: the isolation levels are nothing but retention policies for this one object. Read Committed takes a fresh snapshot per statement; Repeatable Read keeps one for the whole transaction. Same arithmetic, different lifetime.

That's the lever, then: inspect your own snapshot with pg_current_snapshot(), and remember that visibility is pure arithmetic on xids: no clocks, no "latest committed value" lookup. It's why MVCC reads are cheap, and also why old snapshots are expensive: someone has to keep every version they might still need, which is the whole problem the long-transactions lesson picks apart next.

Further reading

MIT Licensed · Every transcript on this site was generated by a real database run against MySQL 8.4.11 and PostgreSQL 18.4 at c52e5c9, and re-proven through psycopg and PyMySQL.