Begin, commit and abort
Why does a commit have a third outcome?
Begin, commit, abort
Begin does less than you think, on purpose
In most systems BEGIN is lazy. Postgres, for example, gives a new transaction only a virtual transaction ID. It does not allocate a real transaction ID (xid) until the first write, because the xid counter is global to the whole server and is therefore a point of contention. A read-only transaction never touches it.
Taking the snapshot (the consistent view of the data that the transaction reads) also happens around here. It is done either once per transaction or once per statement. This timing difference is the entire implementation of Read Committed versus Repeatable Read. Read Committed takes a new snapshot at each statement, so each statement sees what was committed just before it started. Repeatable Read takes one snapshot at the start, so every statement sees the same data. The isolation level is not a policy that the database checks at every read. It is a decision about when to take the snapshot.
Commit: the order of the steps is forced
The commit has five steps, and their order is not a convention:
- Validate, if the scheme is optimistic (Multiversion and optimistic schemes).
- Append the commit record to the log.
- Force the log to stable storage (
fsync). - Release the locks.
- Acknowledge the client ("OK, committed").
Step 4 must come after step 3. The reason is subtle, so let us spell it out. Suppose you released your locks before the log was forced. Another transaction T2 could then read the value you wrote, commit, and be acknowledged. Then you crash before your own force completes. After recovery, your write is gone, but T2 is committed and acknowledged, and T2 had read your write. A committed transaction would have read data that never existed. So durability for a reader requires durability of everything that the reader read. This is the recoverability requirement from the locking discussion (a transaction must not commit before the transactions it read from), extended from abort to crash.
The in-doubt window: the third outcome
Now the sharp consequence. The commit point is the moment the commit record becomes durable, and that moment is unobservable to everyone, including the transaction itself. Between sending the force request and receiving its answer, the outcome is already decided but not yet known. If the machine or the network fails exactly there, recovery will decide, and the client may never learn which way.
So the outcome set that a client can see is not {committed, aborted}. It is
and no mechanism at this level of the system can remove the third element. This is Tomas's wallet top-up. His COMMIT message left; the answer never arrived; the top-up may or may not exist. Every idempotency key, deduplication window and transaction-ID scheme that you meet in application design exists to cope with this irreducible third case (we build such a scheme in Distributed transactions and in the retry section below).
It is the epistemic limit of every crossing, met at the smallest scale. Many people first meet the "in-doubt window" in the context of two-phase commit (Distributed transactions) and conclude that it is a distributed phenomenon. It is not. It exists on one machine with one disk. Two-phase commit only makes it wider.
A correction: "committed when the commit record is written"
The sentence "the transaction commits when the commit record is written" is imprecise. It commits when the record is durable. With group commit (below) that is milliseconds after the write. With asynchronous commit it may be never. In Postgres, synchronous_commit = off gives you A and I but explicitly not D: the server acknowledges before the flush, and a crash can lose the last few transactions. The exposure is bounded by the delay of the WAL writer process (the background process that flushes the log). This is the principle "costs are conserved, never removed" turned into a configuration knob with a published exchange rate: you trade a known, bounded amount of durability for latency. It is the most honest durability setting in common use precisely because it states the size of the window it sells.
Abort is not the mirror image of commit
Commit is O(1): one log record. Abort is O(work done): every change must be undone. So a scheme that aborts often pays twice: once for the wasted work and again for the undo.
But that holds only for in-place update. Look at what happens when the data placement changes:
| Placement | Commit | Abort | Because |
|---|---|---|---|
| In-place + undo log | One log record | Undo every change | The new data is already in the table and visible to the system; only the log says "this does not count" |
| Out-of-place + publish a pointer | Swap the pointer | Free: just orphan the writes | Nothing was visible until the pointer moved; abandoning the writes is a no-op |
Where you put the data decides which of commit and abort is the expensive one. That one table explains several facts:
- Optimistic schemes are affordable in out-of-place systems and punishing in in-place ones.
- Lakehouse aborts cost nothing while lakehouse commits are the contended resource.
- MVCC systems can afford the false-positive aborts of SSI (Multiversion and optimistic schemes): in a multiversion store, an abort is close to free by construction.
An open transaction is never free
Even a transaction that does nothing costs something while it stays open:
- It holds its snapshot, which pins the oldest version that the vacuum (the collector of old row versions) must keep, so the collector cannot clean up.
- Under strict two-phase locking, it holds its locks.
- It pins the log segments that recovery would need.
An "idle in transaction" connection (a client that sent BEGIN and then went to lunch) is therefore a global cost created by a local mistake. This is why every database ships a timeout setting for it. (Tomas's report query from Multiversion and optimistic schemes is the extreme example.)