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.10 and PostgreSQL 18.4 at bd6f201, and re-proven through psycopg and PyMySQL.