Snapshots under the hood
pg_current_snapshot() exposes top-level transaction-id visibility information. Its three components are not the complete tuple-visibility algorithm: commit/abort status, own writes, command order, subtransactions, frozen tuples and row-header flags also matter.
| component | meaning |
|---|---|
xmin | lowest xid still active at snapshot time; smaller xids have completed, either committed or aborted |
xmax | one past the highest completed xid in the documented representation; xids at or above it had not completed when the snapshot was taken |
xip | in-progress top-level xids between the bounds; does not include subtransaction ids |
These are Documented contracts, Table 9.85. Completion alone is not commitment: a completed transaction can be aborted. This representation is a teaching aid for the schedule, not a substitute for PostgreSQL's visibility checks.
The three numbers, live
A's initial ordinary read does not assign an xid; pg_current_xact_id() could assign one without a table write.
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 inspects its retained snapshot bounds; commit status and tuple metadata also matter for 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;
COMMITA 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;
COMMITC's fresh transaction now reads A's committed value; own-write visibility is a separate rule.
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.6 · Run it yourself · Scenario source
A's ordinary read has not assigned an xid, then its UPDATE assigns one. B updates a different row and commits. C takes a Repeatable Read snapshot: it asserts A at xmin and in xip, and xmax equal to B's xid plus one. Its table reads assert A's old value 100 and B's committed 200. A commits, but C's repeat still sees the same values. After C commits, its fresh read sees A's 150.
pg_current_xact_id() can assign an xid even without a table write. Thus the observation that A's initial read had no xid is not a guarantee that read-only transactions never consume one. The transaction-id chapter documents lazy assignment and the distinction between 32-bit xid and epoch-extended xid8.
Under the isolation contract, ordinary Read Committed table reads take a new snapshot per command; Repeatable Read establishes its retained snapshot at the first non-transaction-control statement. Both can see their own earlier writes. UPDATE and locking SELECT can wait or reject changed rows under their level-specific rules. C's demonstration excludes A's newly committed version from an already retained snapshot; it does not make commit order irrelevant to which snapshot a later reader takes. No read-cost benchmark is claimed.