Atomicity Through Versioning

Atomicity in the ACID sense means a series of database operations either all commit together or all roll back — no partial states. When production bugs slip through and modify data, those changes get reverted rather than leaving permanent corruption. Connections dropped mid-request from intermittent issues may cause inconvenience, but they won't scramble your data.

Postgres is known for strong transaction semantics with minimal overhead, yet its internals often remain a black box even to long-time users. Here's what happens under the hood when Postgres manages transactions and commits them atomically.

MVCC Basics

Imagine a simple database reading and writing to a CSV file. A single client works fine, but adding multi-client support immediately creates problems: two clients reading, modifying, and writing overlapping data clobber each other's changes. This is the classic concurrent access problem.

Data loss from contention between two clients.
Data loss from contention between two clients.

Naive solutions exist — exclusive file locks or serializing all operations through one control point — but they're slow and don't scale toward full ACID compliance. Modern databases instead use multi-version concurrency control (MVCC).

Under MVCC, statements execute inside a transaction. Instead of overwriting data directly, operations create new versions of it. The original data remains visible to other clients, while new data stays hidden until the transaction commits. Clients avoid direct contention and can't overwrite each other's changes.

When a transaction starts, it takes a snapshot capturing the database state at that instant. Transactions apply in serial order, with a global lock ensuring only one commits or aborts at a time. A snapshot is therefore a perfect representation of state between two transactions. Obsolete rows accumulate over time, so databases periodically remove them via vacuum operations — but only for data no longer needed by any open snapshot.

Transactions, Tuples, and Snapshots

Postgres identifies transactions with a xid (transaction ID). As an optimization, it only assigns a xid when a transaction starts modifying data — read-only transactions never need one. A transaction's xmin is set immediately to the smallest xid among still-running transactions at start. Vacuum processes use the minimum of all active xmin values to calculate what data must be retained.

Lifetime-Aware Tuples

Rows in Postgres are called tuples. Lookup structures like B-trees speed retrievals but don't store a tuple's full data or visibility information. Instead, indexes hold a tid (tuple ID) pointing to physical storage — "the heap" — where Postgres scans until finding a tuple satisfying the current snapshot's visibility.

Each heap tuple tracks its own xmin (the transaction where it became visible, i.e., the creator) and xmax (the last transaction where it's visible, i.e., the deleter). These internal fields can be exposed as hidden columns:

# SELECT *, xmin, xmax FROM names;

 id |   name   | xmin  | xmax
----+----------+-------+-------
  1 | Hyperion | 27926 | 27928
  2 | Endymion | 27927 |     0

Snapshots: xmin, xmax, and xip

A snapshot structure defines three visibility boundaries:

  • xmin: the lowest xid among running transactions at snapshot creation — a lower boundary; tuples created by transactions with xid < xmin are visible.
  • xmax: the last committed xid plus one — an upper bound; transactions with xid >= xmax are invisible.
  • *xip: an array of all xids in progress when the snapshot was created. This is necessary because some committed transactions may have xids between xmin and the xids of in-flight transactions. We want committed results visible, but in-flight ones hidden — *xip lets us distinguish them.
Transactions executing against a database and a snapshot capturing a moment in time.
Transactions executing against a database and a snapshot capturing a moment in time.

Beginning a Transaction

When you run BEGIN, Postgres sets up basic bookkeeping but defers expensive operations. The transaction gets no xid until it touches data, and no snapshot until its first query — even a trivial SELECT 1; triggers snapshot creation via exec_simple_query.

Snapshot creation happens in GetSnapshotData, which sets xmin, xmax, and *xip. The xmax comes from shared memory managed by the postmaster: every committing transaction notifies the postmaster, which updates latestCompletedXid when the new xid is higher than the stored one.

Deriving xmax isn't a simple increment because xids in Postgres wrap around — they're unsigned 32-bit integers. A high-throughput system can exhaust 32 bits quickly, so wrap-around resets the sequence. Preprocessor logic handles this while skipping the first few reserved IDs (starting at 3).

Back in GetSnapshotData, xmin and xip are populated by iterating over all currently running transactions:

/*
 * Spin over procArray checking xid, xmin, and subxids.  The goal is
 * to gather all active xids, find the lowest xmin, and try to record
 * subxids.
 */
for (index = 0; index < numProcs; index++)
{
    volatile PGXACT *pgxact = &allPgXact[pgprocno];
    TransactionId xid;
    xid = pgxact->xmin; /* fetch just once */

    /*
     * If the transaction has no XID assigned, we can skip it; it
     * won't have sub-XIDs either.  If the XID is >= xmax, we can also
     * skip it; such transactions will be treated as running anyway
     * (and any sub-XIDs will also be >= xmax).
     */
    if (!TransactionIdIsNormal(xid)
        || !NormalTransactionIdPrecedes(xid, xmax))
        continue;

    if (NormalTransactionIdPrecedes(xid, xmin))
        xmin = xid;

    /* Add XID to snapshot. */
    snapshot->xip[count++] = xid;

    ...
}

...

snapshot->xmin = xmin;

Writing the Commit

Commit processing begins in CommitTransaction in xact.c, which handles the transition from the transaction's active state to its final recorded state. The critical path involves two distinct storage systems that work together to make the commit durable and visible.

static void
CommitTransaction(void)
{
    ...

    /*
     * We need to mark our XIDs as committed in pg_xact.  This is where we
     * durably commit.
     */
    latestXid = RecordTransactionCommit();

    /*
     * Let others know about no transaction in progress by me. Note that this
     * must be done _before_ releasing locks we hold and _after_
     * RecordTransactionCommit.
     */
    ProcArrayEndTransaction(MyProc, latestXid);

    ...
}

Durability through the write-ahead log

Postgres treats durability as a core invariant: once a transaction is committed, a crash or power failure must not undo it. The mechanism is the write-ahead log (WAL, or “xlog”). Every change is first written and flushed to the WAL on disk; if the server terminates abruptly, Postgres replays the WAL during recovery to reproduce any changes that had not yet reached the main data files. RecordTransactionCommit is the function that marshals the state change into the WAL:

static TransactionId
RecordTransactionCommit(void)
{
    bool markXidCommitted = TransactionIdIsValid(xid);

    /*
     * If we haven't been assigned an XID yet, we neither can, nor do we want
     * to write a COMMIT record.
     */
    if (!markXidCommitted)
    {
        ...
    } else {
        XactLogCommitRecord(xactStopTimestamp,
                            nchildren, children, nrels, rels,
                            nmsgs, invalMessages,
                            RelcacheInitFileInval, forceSyncCommit,
                            MyXactFlags,
                            InvalidTransactionId /* plain commit */ );

        ....
    }

    if ((wrote_xlog && markXidCommitted &&
         synchronous_commit > SYNCHRONOUS_COMMIT_OFF) ||
        forceSyncCommit || nrels > 0)
    {
        XLogFlush(XactLastRecEnd);

        /*
         * Now we may update the CLOG, if we wrote a COMMIT record above
         */
        if (markXidCommitted)
            TransactionIdCommitTree(xid, nchildren, children);
    }

    ...
}

The commit log bitmap

Alongside the WAL, Postgres maintains a commit log (“clog”, stored under pg_xact) that records, for every transaction ID, whether that transaction committed or aborted. After the WAL write succeeds, TransactionIdCommitTree flips the transaction's status bits in the commit log to “committed”.

The commit log is a bitmap spread across pages in shared memory and on disk. Each transaction's status occupies just two bits, so a single 8k page holds 32,768 status entries. The relevant constants and functions appear in clog.h and clog.c:

#define TRANSACTION_STATUS_IN_PROGRESS      0x00
#define TRANSACTION_STATUS_COMMITTED        0x01
#define TRANSACTION_STATUS_ABORTED          0x02
#define TRANSACTION_STATUS_SUB_COMMITTED    0x03

#define CLOG_BITS_PER_XACT  2
#define CLOG_XACTS_PER_BYTE 4
#define CLOG_XACTS_PER_PAGE (BLCKSZ * CLOG_XACTS_PER_BYTE)

Skipping work when possible

Performance engineering shows up in the commit path as well. A transaction that never received an xid—because it made no changes—skips both the WAL and commit log entirely. An aborted transaction still writes its status to both, but Postgres omits the immediate fsync: after a crash, recovery simply assumes that any transaction without a properly flushed abort/commit record was aborted, so no information is lost.

Subcommits and crash safety

TransactionIdCommitTree (declared in transam.c, implemented as TransactionIdSetTreeStatus in clog.c) handles commits that involve subtransactions. Because the whole tree cannot be marked committed atomically, the code records each subcommit individually and saves the parent transaction's commit mark for last. During crash recovery, subcommit records alone are not treated as committed; the parent's record must also be present and show a commit. This ordering guarantees that a crash midway through the tree write cannot leave the system believing a partially recorded commit succeeded:

/*
 * Record the final state of transaction entries in the commit log for
 * all entries on a single page.  Atomic only on this page.
 *
 * Otherwise API is same as TransactionIdSetTreeStatus()
 */
static void
TransactionIdSetPageStatus(TransactionId xid, int nsubxids,
                           TransactionId *subxids, XidStatus status,
                           XLogRecPtr lsn, int pageno)
{
    ...

    LWLockAcquire(CLogControlLock, LW_EXCLUSIVE);

    /*
     * Set the main transaction id, if any.
     *
     * If we update more than one xid on this page while it is being written
     * out, we might find that some of the bits go to disk and others don't.
     * If we are updating commits on the page with the top-level xid that
     * could break atomicity, so we subcommit the subxids first before we mark
     * the top-level commit.
     */
    if (TransactionIdIsValid(xid))
    {
        /* Subtransactions first, if needed ... */
        if (status == TRANSACTION_STATUS_COMMITTED)
        {
            for (i = 0; i < nsubxids; i++)
            {
                Assert(ClogCtl->shared->page_number[slotno] == TransactionIdToPage(subxids[i]));
                TransactionIdSetStatusBit(subxids[i],
                                          TRANSACTION_STATUS_SUB_COMMITTED,
                                          lsn, slotno);
            }
        }

        /* ... then the main transaction */
        TransactionIdSetStatusBit(xid, status, lsn, slotno);
    }

    ...

    LWLockRelease(CLogControlLock);
}

Making the commit visible

Only after the commit log records the transaction as committed does Postgres signal the rest of the system. The second call inside CommitTransaction updates shared memory via procarray.c:

void
ProcArrayEndTransaction(PGPROC *proc, TransactionId latestXid)
{
    /*
     * We must lock ProcArrayLock while clearing our advertised XID, so
     * that we do not exit the set of "running" transactions while someone
     * else is taking a snapshot.  See discussion in
     * src/backend/access/transam/README.
     */
    if (LWLockConditionalAcquire(ProcArrayLock, LW_EXCLUSIVE))
    {
        ProcArrayEndTransactionInternal(proc, pgxact, latestXid);
        LWLockRelease(ProcArrayLock);
    }

    ...
}

static inline void
ProcArrayEndTransactionInternal(PGPROC *proc, PGXACT *pgxact,
                                TransactionId latestXid)
{
    ...

    /* Also advance global latestCompletedXid while holding the lock */
    if (TransactionIdPrecedes(ShmemVariableCache->latestCompletedXid,
                              latestXid))
        ShmemVariableCache->latestCompletedXid = latestXid;
}

The proc array is the shared-memory registry of all active backend processes. Postgres uses a forking model rather than threads: the postmaster forks a new backend process for each connection (see postmaster.c), and each backend is represented by a PGPROC structure (defined in proc.h).

The key update here sets latestCompletedXid in shared memory to the just-committed transaction's xid. Recall that a snapshot's xmax is constructed as latestCompletedXid + 1; advancing this global value makes the commit visible to every snapshot taken afterward, on any backend. The LWLockConditionalAcquire and LWLockRelease calls protect this update—most of the time backends run in parallel, but updating latestCompletedXid requires exclusive access so that commits are serialized correctly.

Throughout the commit process the client waits synchronously for a response. Part of the atomicity contract is that false positives are impossible: if anything fails, the client receives an error and can retry or otherwise respond. A success message is sent only after all of the above steps have completed.

Visibility checks at scan time

When a query scans a table, heapgettup (in heapam.c) walks the heap looking for tuples that match the snapshot's criteria:

static void
heapgettup(HeapScanDesc scan,
           ScanDirection dir,
           int nkeys,
           ScanKey key)
{
    ...

    /*
     * advance the scan until we find a qualifying tuple or run out of stuff
     * to scan
     */
    lpp = PageGetItemId(dp, lineoff);
    for (;;)
    {
        /*
         * if current tuple qualifies, return it.
         */
        valid = HeapTupleSatisfiesVisibility(tuple,
                                             snapshot,
                                             scan->rs_cbuf);

        if (valid)
        {
            return;
        }

        ++lpp;            /* move forward in this page's ItemId array */
        ++lineoff;
    }

    ...
}

The macro HeapTupleSatisfiesVisibility dispatches to a function such as HeapTupleSatisfiesMVCC in tqual.c:

bool
HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot,
                       Buffer buffer)
{
    ...

    else if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmin(tuple), snapshot))
        return false;
    else if (TransactionIdDidCommit(HeapTupleHeaderGetRawXmin(tuple)))
        SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED,
                    HeapTupleHeaderGetRawXmin(tuple));

    ...

    /* xmax transaction committed */

    return false;
}

XidInMVCCSnapshot performs the first test: is the tuple's xid inside the snapshot's visibility window, as defined by xmin, xmax, and the xip list of in-progress transactions? A simplified version of the logic appears in tqual.c:

static bool
XidInMVCCSnapshot(TransactionId xid, Snapshot snapshot)
{
    /* Any xid < xmin is not in-progress */
    if (TransactionIdPrecedes(xid, snapshot->xmin))
        return false;
    /* Any xid >= xmax is in-progress */
    if (TransactionIdFollowsOrEquals(xid, snapshot->xmax))
        return true;

    ...

    for (i = 0; i < snapshot->xcnt; i++)
    {
        if (TransactionIdEquals(xid, snapshot->xip[i]))
            return true;
    }

    ...
}

Note the inverted return convention: a false return means the xid is visible. If the xid passes this preliminary check, Postgres then consults the canonical commit status via TransactionIdDidCommit in transam.c:

bool /* true if given transaction committed */
TransactionIdDidCommit(TransactionId transactionId)
{
    XidStatus xidstatus;

    xidstatus = TransactionLogFetch(transactionId);

    /*
     * If it's marked committed, it's committed.
     */
    if (xidstatus == TRANSACTION_STATUS_COMMITTED)
        return true;

    ...
}

That function routes through TransactionLogFetch, which computes the commit-log page and offset for the given transaction ID and reads the two status bits. For consistency, the commit log is authoritative for visibility; it returns the same answer whether the commit happened long ago or just before a crash that recovery is still processing. (xid is always less than xmax) —actually xid >= xmax means not visible, so the code checks that first; then it checks whether the xid appears in xip; if not, the tuple is considered visible and a true value is returned to indicate that the transaction is not in progress.

Hint bits avoid repeat lookups

HeapTupleSatisfiesMVCC performs one extra action before returning:

SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED,
            HeapTupleHeaderGetRawXmin(tuple));

Reading the commit log for every tuple scan is expensive. To avoid repeating that work, Postgres sets “hint bits” on the scanned tuple—flags that record whether its xmin and xmax transactions are known committed. Later operations check the hint bits first and only fall back to the commit log when the bits are not yet set.

The abstraction holds

BEGIN;

SELECT * FROM users WHERE email = '[email protected]';

INSERT INTO users (email) VALUES ('[email protected]')
    RETURNING *;

COMMIT;

A SQL transaction statement hides an enormous amount of machinery: tuple headers with lifetime metadata, snapshot construction, WAL flushing, commit-log bitmaps, and shared-memory coordination. Postgres presents a simple, reliable interface precisely because the internals enforce atomicity at each layer. The commit log is the final arbiter of whether a transaction happened, and every visibility check ultimately defers to it.