When Did This Transaction Happen? PostgreSQL Snapshots, LSNs, Oracle SCNs, and More
Why Does "When" Matter?
When did a database change happen? This isn't just academic. Incremental replication must order committed changes and resume exactly without locking writers. Migration validation checks if source and target are the same state, even as both change. Audit and incident analysis reconstruct who changed data, when, and when others could see it. Application-log correlation links request timestamps with database transactions and resulting commits.
It has business meaning. An editor changes a document at 10:00, saves at 10:04 to publish it, and gets confirmation at 10:05. Other sessions can't see the uncommitted change at 10:00, but later services might see this timestamp. Which moment should an updated_at field report? If correlating with an application log, the request or statement time may be correct. If describing the user's experience, it may be wrong: users distinguish "I edited", "I saved", and "I published". These are separate business events. Commit visibility, durability, client acknowledgment, and downstream publication are separate system events.
In a nonblocking MVCC database, there is no universal updated_at. A row version can be created, stay private, become visible at commit, reach replicas, and be shown to users later. The correct timestamp depends on the application's question. That sounds like one question, but it is at least five:
- When did the transaction begin?
- Which committed state did a statement read?
- When was a new row version created?
- When did the transaction become committed and visible?
- Which durable log position protects that commit?
Database-Specific Coordinates
PostgreSQL presents different coordinates: xmin:xmax:xip_list for transaction visibility, an LSN for write-ahead log position, and a transaction ID for tuple version changes. None are wall-clock timestamps.
Oracle appears more unified, using the System Change Number (SCN) for read consistency, transaction commits, checkpoints, and recovery. However, claiming "Oracle has only an SCN" is inaccurate, as it also requires a transaction ID, undo address, and redo position.
YugabyteDB employs HybridTime as the MVCC read and commit coordinator, but provisional writes initially have different timestamps. SQL Server, MySQL/InnoDB, and MongoDB/WiredTiger split these responsibilities.
The key comparison isn't "which database has the best clock?" but rather: "which coordinator answers which ordering question?"
AI disclaimer: I wrote this with a lot of help from GitHub Copilot. I used it to make the comparison more thorough and to check each equivalence against the original documentation and code. Any interpretation and remaining errors are my own.
One Transaction Has Several Times
Consider this deliberately generic sequence:
BEGIN -> choose read point -> UPDATE -> COMMIT (some databases may run the following in different order) -> make log durable (disk or replicas) -> make changes visible -> reply (successful commit)
Some of these events can coincide in one implementation, but they remain different promises:
| Moment | Question it answers |
|---|---|
| Transaction start | When did this unit of work begin? |
| Read point | Which other transactions are visible to this statement? |
| Version creation | Which transaction produced this physical row state? |
| Commit point | From which logical point may new readers see the work? |
| Durable log point | How far must recovery or replication progress to include it? |
| Client reply | When did this particular client learn the outcome? |
The logical visibility rule is similar in all MVCC systems. Here, v is a candidate row or document version, and q is the query evaluating whether it can see that version:
visible (v,q) = ownTransaction(v,q) OR ( committed(v) AND ( commitPoint(v) <= readPoint(q) ) )
This is a model, not a specific product implementation. PostgreSQL and InnoDB don't store commitPoint as a per-row scalar. They determine it from transaction ID, status, and active transactions in a snapshot. Oracle and YugabyteDB clarify the logical commit process, but all engines require the ownTransaction exception for uncommitted changes.
Why the Commit Coordinator is Usually Somewhere Else
This separation exists for a physical reason. The final commit coordinate does not exist when the transaction modifies its first row. By commit time, one transaction may have changed millions of rows, and many dirty pages may already have left memory. Revisiting all of them would make commit latency a function of transaction size and would undermine write-ahead logging's no-force rule: commit should make the log durable, not force every data page.
The common answer is indirection. A row version records a transaction marker or provisional time. Commit publishes the outcome and final coordinate in transaction or log metadata. Readers resolve the marker through that metadata; cleanup later may copy enough info into blocks or final versions to avoid lookup.
The implementations differ, but the pressure is the same:
- PostgreSQL: tuples retain
xmin/xmax. Commit status lives inpg_xact, and the optionalpg_commit_tsside data maps XID to wall-clock commit time. - Oracle: rows refer through an ITL entry and XID to transaction-table metadata where commit records the SCN. Block cleanout can happen later.
- YugabyteDB: first writes provisional intents. The status tablet records one final commit HybridTime, and asynchronous apply later creates regular records at that time.
- InnoDB: rows retain
DB_TRX_IDandDB_ROLL_PTR. An internal transaction serialization number is assigned near commit for purge ordering, but it is not copied into each row version.
This explains retention limits. If an engine keeps the XID-to-commit-time mapping as auxiliary metadata, it can age independently of the business row. Recovering an exact commit time years later differs from deciding visibility while the version history is still live.
PostgreSQL: pg_current_snapshot() is a Visibility Boundary
PostgreSQL documents the text representation of a pg_snapshot as xmin:xmax:xip_list. For example:
select pg_current_snapshot ();
pg_current_snapshot
---------------------
10 : 20 : 10 , 14 , 15
The three components have precise meanings:
xminis the lowest transaction ID that was still active. Lower IDs have completed, either by committing or rolling back.xmaxis one past the highest transaction ID that had completed. IDs at or above it had not completed at snapshot time and are invisible to this snapshot.xip_listcontains the top-level transactions that were still in progress between the two horizons. It does not list subtransaction IDs.
An ID between xmin and xmax that is absent from xip_list has completed. Its commit status then says whether it is visible or dead. This is why the snapshot is a visibility boundary over transaction identities, not a timestamp and not a list of all committed transactions.
There is also an unfortunate name collision. Snapshot xmin and xmax are horizons. Tuple xmin and xmax are transaction IDs in a row-version header: the inserting transaction and, normally, the deleting or superseding transaction. The visibility algorithm relates them, but they are not the same field.
XID Order is First-Write Order, Not Commit Order
A PostgreSQL transaction initially has a virtual transaction ID. A normal 32-bit XID is allocated from a cluster-wide counter when the transaction first writes to the database. A read-only transaction may never get one. Calling pg_current_xact_id() forces allocation; pg_current_xact_id_if_assigned() does not.
The documentation makes the ordering guarantee narrow: a lower XID started writing before a higher XID. It may have started the SQL transaction later, and it may commit much later. This schedule is possible:
- T1 first write -> XID 100 -> remains open
- T2 first write -> XID 101 -> commits
- Reader snapshot ->
100:102:100
The reader can see committed work from 101 while 100 is still invisible. A single high-water mark could not describe that state; the exception list is the important part.
This ordering also explains PostgreSQL's famous transaction ID wraparound problem. The XID stored in tuple headers is only 32 bits. Normal XIDs are compared with modulo-2³² arithmetic, so any XID has about two billion values considered older and two billion considered newer. VACUUM must freeze sufficiently old tuple versions before they cross that half-range and appear to come from the future. PostgreSQL's public xid8 adds an epoch for observation, but ordinary heap tuple headers still carry the compact 32-bit XID.
Read Time
At READ COMMITTED, each command starts with a new snapshot. Two SELECT statements in one transaction can therefore see different commits. At REPEATABLE READ and SERIALIZABLE, the transaction keeps the snapshot chosen for its first non-transaction-control statement.
In all cases, the current transaction's earlier commands require additional self-visibility and command ID rules that are not serialized in the public xmin:xmax:xip_list string.
PostgreSQL can export this read point with pg_export_snapshot() and import it in another transaction with SET TRANSACTION SNAPSHOT. The token remains valid only while the exporting transaction stays open. Parallel pg_dump uses synchronized snapshots so all workers see identical contents, and pg_dump --snapshot can align a dump with another session or a logical replication slot. This is often the right coordinate for comparing a source and target during migration: first agree on the state being compared, then compare the rows.
Update Time
An UPDATE normally marks the old tuple with the updater's XID in xmax and creates a replacement tuple with that XID in xmin. The transaction also emits WAL records for WAL-logged storage. At this point, another transaction cannot infer a commit time from the tuple. It sees an XID whose status may still be in progress, committed, or aborted.
Commit Time and WAL Time
PostgreSQL's pg_lsn is a 64-bit byte position in the WAL stream. WAL records are appended, and their insert positions increase monotonically. The following three positions are deliberately distinct:
select pg_current_wal_insert_lsn (), pg_current_wal_lsn (), pg_current_wal_flush_lsn ();
- The insert LSN is the logical end after records have been inserted into shared WAL buffers.
- The write LSN is how far those buffers have been written out.
- The flush LSN is how far PostgreSQL knows the WAL is on durable storage.
An LSN sampled after an UPDATE does not identify the visibility of that update. Other backends write to the same WAL stream, so their records can be between this transaction's records. The tuple itself does not store its WAL LSN.
There is an important qualification to the slogan "an LSN is only a byte position." For a write transaction, the position of its commit record determines its order among other records in the WAL stream. PostgreSQL's logical decoding API provides a commit_lsn, and the documentation states that concurrent transactions are decoded in commit order. So these are both true:
- A generic current LSN is not a transaction snapshot or a commit time.
- The LSN of a specific commit record is a useful order for committed change streams.
That order still does not say which client received its success response first. Group commit can flush several commit records together, and process or network scheduling can reorder the replies. With synchronous_commit set to off, PostgreSQL can report success before that commit record reaches durable storage. Logical decoding waits until the transaction has safely been flushed.
PostgreSQL marks the XID committed in pg_xact. If track_commit_timestamp is on, which is not the default, it also retains a wall-clock commit timestamp that can be queried with pg_xact_commit_timestamp(). The mapping is stored separately under pg_commit_ts and is WAL-logged for recovery and physical replication. It is not added to tuple headers, and vacuum routinely removes old entries once their XIDs are no longer needed. This is optional historical metadata, not the MVCC snapshot coordinate and not a permanent audit trail.
Replication Adds More Positions, Not a Global Clock
Physical streaming replication turns one WAL position into a pipeline. On the primary, it inserts, writes, and flushes a record. A standby then receives, writes, flushes, and replays it. pg_stat_replication exposes the standby's write_lsn, flush_lsn, and replay_lsn as reported to the sender.
flowchart LR
I[Primary insert] --> W[Primary write]
W --> F[Primary flush]
F --> R[Standby receive]
R --> SW[Standby write]
SW --> SF[Standby flush]
SF --> A[Standby replay]
A --> V[Visible to standby queries]
The synchronous_commit mode selects which boundary a committing session must wait for. In the usual synchronous-standby configuration:
| Mode | Commit may return after |
|---|---|
off |
the local commit record is inserted, with no durability wait; flush can lag by up to three times wal_writer_delay |
local |
local durable flush, without waiting for a synchronous standby |
remote_write |
a synchronous standby has written WAL to its operating system |
remote_apply |
a synchronous standby has durably flushed WAL |
| (unnamed) | a synchronous standby has replayed the commit so queries can see it |
These modes change acknowledgment and durability, not the transaction's MVCC snapshot. They also explain why "committed" needs a subject: committed in the primary's transaction state, durable locally, durable remotely, and visible on a standby are distinct observations.
PostgreSQL 19, still in beta as I write this, makes those boundaries directly waitable:
WAIT FOR LSN '0/306EE20' ;
WAIT FOR LSN '0/306EE20' WITH ( MODE 'standby_flush' , TIMEOUT '5s' );
The default standby_replay mode is useful for read-your-writes on an asynchronous replica. Other modes wait for standby write, standby flush, or primary flush. This does not turn the LSN into an MVCC snapshot: the client must capture the relevant primary LSN, and WAIT FOR compares its numeric value without identifying the timeline. Promotion therefore requires the application to reconsider whether the
Comments
No comments yet. Start the discussion.