---
title: "04 — ACID, Transactions, and MVCC: How Postgres Stays Correct"
uid: acid-transactions-mvcc
tags: ["roadmap:postgresql-dba", "postgresql", "acid", "internals", "wal", "transactions", "mvcc"]
excerpt: "MVCC gives every transaction its own consistent snapshot (readers never block writers), and the WAL records every change before it lands — so a crash becomes a replayable ledger, not a disaster."
date: 2026-08-13T03:27:53+0000
source: https://www.aveshina.my.id/en/blog/acid-transactions-mvcc
---

"Transactions just work" was my ACID mental model, which made every concurrency surprise feel like a glitch. Writing it down pinned the two mechanisms everything else is built on: **Multi-Version Concurrency Control gives each transaction its own consistent snapshot of the data so readers never block writers, and the Write-Ahead Log records every change before it lands so a crash becomes a replayable ledger, not a disaster.** [1][2] Once those two clicked, the rest of the DBA playbook stopped feeling like a list of chores and started looking like consequences of this design.

## ACID, recast as engineering commitments

ACID is the contract, but the letters are not equally obvious. The two I had to think hardest about were **Isolation** and **Durability**, because they're where the real machinery lives [3]:

- **Atomicity** — a transaction is all-or-nothing. Either every statement commits or none does. No half-applied writes.
- **Consistency** — a transaction moves the database from one valid state to another, respecting all constraints and triggers.
- **Isolation** — concurrent transactions appear to execute serially; one doesn't see another's intermediate, uncommitted results. The strictest level is Serializable, but the default in Postgres is Read Committed.
- **Durability** — once committed, the change survives a crash. This is what the WAL buys.

Atomicity and consistency are mostly enforced by transaction boundaries and constraints. Isolation and durability are where Postgres spends its real engineering, via MVCC and the WAL respectively.

## Transactions: the all-or-nothing boundary

A **transaction** is a group of statements wrapped in BEGIN ... COMMIT (or ROLLBACK to abort) [4]. Inside it, either everything sticks or nothing does:

```
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1;
  UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
```

If the second UPDATE fails, the first is undone — the money doesn't vanish. **Savepoints** let me carve out sub-boundaries: SAVEPOINT pre_validation lets a later error roll back to that point without aborting the whole transaction. The isolation level (SET TRANSACTION ISOLATION LEVEL SERIALIZABLE) controls how much this transaction sees of others running alongside it.

## MVCC: readers don't block writers

The model I had to internalize is **Multi-Version Concurrency Control** [1]. When a transaction updates a row, Postgres doesn't overwrite the row — it writes a *new version* and marks the old one with the transaction ID that created it and the one that superseded it. Every transaction gets a snapshot: it can see only the row versions that were committed before the transaction (or statement, under Read Committed) started.

```figure
<svg viewBox="0 0 740 320" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="MVCC snapshot isolation. A table row shows three versions stacked vertically: version 1 created by txn 100, version 2 created by txn 200 and marked as superseding version 1, version 3 created by txn 300 still uncommitted. Two transaction timelines on the right: txn A started before 200 so sees version 1; txn B started after 200 so sees version 2; neither sees uncommitted version 3. No locks block the writer.">
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- Row versions stack -->
    <text x="40" y="30" font-size="12" font-weight="700" fill="#1e1b4b">row id=42 (physical versions)</text>

    <rect x="40" y="44" width="280" height="42" rx="6" fill="#dcfce7" stroke="#16a34a" stroke-width="1.4"/>
    <text x="55" y="62" font-size="11" font-family="ui-monospace, monospace" fill="#052e16">v1: balance=500</text>
    <text x="55" y="78" font-size="9" fill="#052e16">xmin=100  xmax=200  (superseded)</text>

    <rect x="40" y="94" width="280" height="42" rx="6" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.4"/>
    <text x="55" y="112" font-size="11" font-family="ui-monospace, monospace" fill="#1e1b4b">v2: balance=400</text>
    <text x="55" y="128" font-size="9" fill="#1e1b4b">xmin=200  xmax=null  (current)</text>

    <rect x="40" y="144" width="280" height="42" rx="6" fill="#fee2e2" stroke="#dc2626" stroke-width="1.4" stroke-dasharray="4 3"/>
    <text x="55" y="162" font-size="11" font-family="ui-monospace, monospace" fill="#7f1d1d">v3: balance=350</text>
    <text x="55" y="178" font-size="9" fill="#7f1d1d">xmin=300  uncommitted (hidden)</text>

    <!-- Transactions -->
    <text x="430" y="30" font-size="12" font-weight="700" fill="#1e1b4b">concurrent readers</text>

    <rect x="430" y="44" width="280" height="50" rx="6" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.4"/>
    <text x="445" y="64" font-size="11" font-weight="700" fill="#422006">txn A (started before 200)</text>
    <text x="445" y="80" font-size="10" font-family="ui-monospace, monospace" fill="#422006">sees v1: balance=500</text>

    <rect x="430" y="104" width="280" height="50" rx="6" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.4"/>
    <text x="445" y="124" font-size="11" font-weight="700" fill="#422006">txn B (started after 200)</text>
    <text x="445" y="140" font-size="10" font-family="ui-monospace, monospace" fill="#422006">sees v2: balance=400</text>

    <rect x="430" y="164" width="280" height="50" rx="6" fill="#fce7f3" stroke="#db2777" stroke-width="1.4"/>
    <text x="445" y="184" font-size="11" font-weight="700" fill="#500724">txn C (any)</text>
    <text x="445" y="200" font-size="10" font-family="ui-monospace, monospace" fill="#500724">cannot see v3 — uncommitted</text>

    <!-- connector -->
    <text x="370" y="120" font-size="9" fill="#64748b" text-anchor="middle">snapshot</text>
    <text x="370" y="180" font-size="9" fill="#64748b" text-anchor="middle">snapshot</text>

    <text x="370" y="260" font-size="11" fill="#475569" text-anchor="middle" font-style="italic">no read locks — each transaction reads its visible version</text>
    <text x="370" y="278" font-size="11" fill="#475569" text-anchor="middle" font-style="italic">dead versions (v1) await VACUUM</text>
  </g>
</svg>
```

The payoff is that **readers never block writers, and writers never block readers.** A long analytic query can run for minutes against a snapshot while inserts and updates stream in, and neither side waits. The cost is the dead row versions — old versions no longer visible to any transaction — which is exactly why VACUUM exists: to reclaim that space and update the planner's statistics [1].

## The WAL: durability as a replayable ledger

**Durability** is bought by the **Write-Ahead Log (WAL)** [2]. The rule is simple and absolute: *before* a changed data page is written to disk, the log record describing that change is written and flushed to the WAL. The WAL is an append-only sequence of records — every insert, update, delete, and schema change, in the order it happened.

Why "write-ahead": if Postgres wrote data pages first and the log second, a crash between the two would leave changed data on disk with no record of how it got there — unrecoverable. By logging first, the recovery story becomes clean:

```figure
<svg viewBox="0 0 740 280" xmlns="http://www.w3.org/2000/svg" class="my-6 w-full max-w-2xl" role="img" aria-label="Write-Ahead Log crash recovery. Left: a transaction changes a data page. Middle: the change is first appended to the WAL ledger (numbered entries 1,2,3). Right: a crash bolt strikes the data files. Below: on restart, Postgres replays WAL entries forward (redo) to rebuild committed changes and rolls back uncommitted ones, restoring a consistent state.">
  <defs>
    <marker id="warrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
      <path d="M0,0 L10,5 L0,10 z" fill="#64748b"/>
    </marker>
  </defs>
  <g font-family="ui-sans-serif, system-ui, sans-serif" text-rendering="geometricPrecision">

    <!-- step 1: change -->
    <rect x="20" y="40" width="120" height="70" rx="8" fill="#e0e7ff" stroke="#6366f1" stroke-width="1.5"/>
    <text x="80" y="62" font-size="11" font-weight="700" fill="#1e1b4b" text-anchor="middle">① change</text>
    <text x="80" y="80" font-size="10" fill="#1e1b4b" text-anchor="middle">UPDATE row 42</text>
    <text x="80" y="96" font-size="10" fill="#1e1b4b" text-anchor="middle">in memory</text>

    <!-- step 2: WAL first -->
    <rect x="200" y="40" width="160" height="70" rx="8" fill="#dcfce7" stroke="#16a34a" stroke-width="1.5"/>
    <text x="280" y="62" font-size="11" font-weight="700" fill="#052e16" text-anchor="middle">② append to WAL first</text>
    <text x="280" y="80" font-size="9" font-family="ui-monospace, monospace" fill="#052e16" text-anchor="middle">LSN 001: +row v2</text>
    <text x="280" y="94" font-size="9" font-family="ui-monospace, monospace" fill="#052e16" text-anchor="middle">LSN 002: commit</text>

    <!-- step 3: data page later -->
    <rect x="420" y="40" width="120" height="70" rx="8" fill="#fef9c3" stroke="#ca8a04" stroke-width="1.5"/>
    <text x="480" y="62" font-size="11" font-weight="700" fill="#422006" text-anchor="middle">③ flush page</text>
    <text x="480" y="80" font-size="10" fill="#422006" text-anchor="middle">data file written</text>
    <text x="480" y="96" font-size="10" fill="#422006" text-anchor="middle">(later, lazily)</text>

    <path d="M140,75 L198,75" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#warrow)"/>
    <path d="M360,75 L418,75" fill="none" stroke="#64748b" stroke-width="1.5" marker-end="url(#warrow)"/>

    <!-- crash -->
    <text x="600" y="50" font-size="16" fill="#dc2626" text-anchor="middle">⚡ crash</text>
    <rect x="560" y="60" width="140" height="50" rx="8" fill="#fee2e2" stroke="#dc2626" stroke-width="1.5"/>
    <text x="630" y="80" font-size="10" font-weight="700" fill="#7f1d1d" text-anchor="middle">on restart</text>
    <text x="630" y="96" font-size="9" fill="#7f1d1d" text-anchor="middle">replay WAL forward</text>

    <!-- recovery line -->
    <line x1="280" y1="115" x2="630" y2="115" stroke="#64748b" stroke-width="1.5" stroke-dasharray="4 3" marker-end="url(#warrow)"/>
    <text x="455" y="135" font-size="10" fill="#475569" text-anchor="middle">redo committed, undo uncommitted → consistent</text>

    <text x="370" y="200" font-size="11" fill="#475569" text-anchor="middle" font-style="italic">every change is logged before it lands</text>
    <text x="370" y="218" font-size="11" fill="#475569" text-anchor="middle" font-style="italic">a crash replays the ledger — no committed data is lost</text>
  </g>
</svg>
```

On restart after a crash, Postgres replays the WAL forward (redoing committed changes that hadn't reached the data files) and rolls back any transactions that never committed. The result is a consistent state with no committed change lost [2]. The same WAL is what streaming replication ships to standbys — the standby applies the same log and stays in sync.

## Query processing: how a statement becomes a plan

Tucked alongside these mechanisms is **query processing** — the path a SQL statement takes from text to result [7]. Parsing checks syntax; the **planner/optimizer** picks an execution strategy (which join order, which index, sequential scan vs. index scan) based on table statistics; the **executor** runs the plan and returns rows. The planner's quality is why EXPLAIN and statistics matter so much — a bad plan turns a millisecond query into a minute. The notes on indexes and EXPLAIN come back to this machinery.

## How I use this

Three habits fall out of these mechanisms. First, I wrap multi-statement logical units in explicit BEGIN/COMMIT so the all-or-nothing guarantee is mine, not accidental. Second, when I see slow reads under heavy writes, I check the isolation level and the autovacuum settings — MVCC dead rows bloat tables and indexes if vacuuming falls behind, and the symptom is exactly creeping read latency. Third, I treat the WAL as the source of truth for durability and recovery: a backup without the archived WAL is a backup that can only restore to one point in time, which is often not enough. The combination — snapshot isolation plus a replayable log — is what makes Postgres feel trustworthy under concurrency and crash.

## References

[1] PostgreSQL Global Development Group, "Introduction to MVCC," 2024. [Online]. Available: [https://www.postgresql.org/docs/current/mvcc-intro.html](https://www.postgresql.org/docs/current/mvcc-intro.html)

[2] PostgreSQL Global Development Group, "Reliability and the Write-Ahead Log," 2024. [Online]. Available: [https://www.postgresql.org/docs/current/wal-intro.html](https://www.postgresql.org/docs/current/wal-intro.html)

[3] Retool, "What is an ACID compliant database?," 2023. [Online]. Available: [https://retool.com/blog/whats-an-acid-compliant-database/](https://retool.com/blog/whats-an-acid-compliant-database/)

[4] PostgreSQL Global Development Group, "Transactions," 2024. [Online]. Available: [https://www.postgresql.org/docs/current/tutorial-transactions.html](https://www.postgresql.org/docs/current/tutorial-transactions.html)

[5] Wikipedia, "Multiversion concurrency control," 2024. [Online]. Available: [https://en.wikipedia.org/wiki/Multiversion_concurrency_control](https://en.wikipedia.org/wiki/Multiversion_concurrency_control)

[6] Hevo Data, "Working With Postgres WAL Made Easy 101," 2023. [Online]. Available: [https://hevodata.com/learn/working-with-postgres-wal/](https://hevodata.com/learn/working-with-postgres-wal/)

[7] InterDB, "Query Processing in PostgreSQL," 2024. [Online]. Available: [https://www.interdb.jp/pg/pgsql03.html](https://www.interdb.jp/pg/pgsql03.html)

```quiz
Q: Under MVCC, what happens when a transaction updates a row?
- The row is overwritten in place and other transactions block reading it
- A new version of the row is written; the old version stays and is visible to transactions whose snapshot predates the update
correct: 1
explain: MVCC keeps old row versions so each transaction sees a consistent snapshot. Readers don't block writers. Dead versions are later reclaimed by VACUUM.

Q: What does the "write-ahead" rule in WAL guarantee?
- Data pages are written to disk before the log
- The log record for a change is flushed before the changed data page, so recovery can always replay committed changes
correct: 1
explain: Logging first means a crash between log and data still leaves a complete record of the change, which Postgres replays on restart to restore a consistent state.

Q: What is the default transaction isolation level in PostgreSQL?
- Serializable
- Read Committed
correct: 1
explain: Postgres defaults to Read Committed, where each statement sees a snapshot at the statement's start. Serializable is opt-in for stricter guarantees.

Q: After a crash, how does PostgreSQL restore a consistent state?
- It restores from the last full backup automatically
- It replays the WAL forward to redo committed changes and rolls back uncommitted transactions
correct: 1
explain: Crash recovery replays the WAL: committed-but-unflushed changes are redone, uncommitted ones are undone. No committed data is lost.

Q: Why does VACUUM exist in a Postgres that uses MVCC?
- To optimize the query planner's joins
- To reclaim storage held by dead row versions that MVCC leaves behind after updates and deletes
correct: 1
explain: Because updates and deletes create dead versions rather than overwriting, those versions accumulate. VACUUM removes them, preventing bloat and updating planner statistics.
```
