← databases book ⊞ All topics

Write-Ahead Log (WAL) — Crash Recovery & the Durability Guarantee

Before any change touches a data page, Postgres first appends a description of it to an append-only, sequentially-written log and flushes that to disk. The data pages may lag indefinitely — after any crash, replaying the log reconstructs every change that never reached them. Log first, data later; everything else is a consequence.

“Changes to data files must be written only after those changes have been logged, that is, after WAL records describing the changes have been flushed to permanent storage.” — PostgreSQL documentation, the write-ahead rule

Key Components

Write-ahead rule
The single invariant the whole system rests on: a change may be written to a data file only after the WAL record describing it has been flushed to permanent storage. The log always reaches disk before the data pages it describes.
LSN (Log Sequence Number)
A WAL record's byte position in the logical WAL stream. LSNs only ever increase, giving every change a total order. pg_current_wal_lsn() reports the current write position.
pd_lsn (page LSN)
A field in every data page's header holding the LSN of the last WAL record that modified that page. Pairing a record's LSN against a page's pd_lsn is what drives — and idempotently bounds — recovery replay.
Checkpoint / redo point
A checkpoint flushes all currently-dirty data pages to disk and writes a checkpoint record; its redo point is the WAL position at checkpoint start. Everything logged before the redo point is guaranteed to be in the data files, so recovery begins there and older WAL can be recycled.
Full-page write (backup block)
The first modification of a page after each checkpoint writes the whole page image into the WAL. During recovery these images are applied unconditionally — the torn-page defense, since a partially-written page's pd_lsn is untrustworthy.
synchronous_commit
The durability/latency dial. on (default) makes COMMIT wait for the WAL fsync → zero committed-data loss. off returns before the flush → faster, but a crash may lose the last fraction of a second of commits. It never corrupts.
wal_level
How much the WAL records. minimal = crash recovery only; replica (default) adds archiving, streaming replication, and PITR; logical adds row-level logical decoding.

Concrete Example

The heart of WAL is the recovery loop. On an unclean startup — detected via pg_control — Postgres finds the redo point and replays the WAL forward, deciding record-by-record whether each change already reached its page. The decision is a single comparison of the record's LSN against the page's pd_lsn:

-- Unclean startup: pg_control shows the last shutdown was not clean.
redo_lsn = checkpoint.redo          -- replay begins here

for each WAL record R, in LSN order, starting at redo_lsn:
    page = target_page(R)

    if R.is_full_page_image:         -- first touch of page after a checkpoint
        overwrite page with the image     -- UNCONDITIONAL — torn-page defense
        page.pd_lsn = R.lsn

    else if R.lsn > page.pd_lsn:     -- change had NOT reached the page
        apply R to page
        page.pd_lsn = R.lsn

    else:                            -- R.lsn <= page.pd_lsn
        skip R                       -- already applied; do nothing

The R.lsn > page.pd_lsn test is the whole trick. If the page already carries an LSN at least as high as the record's, the change is already reflected there, so replaying it would be redundant — it is skipped. That makes replay idempotent: it is safe to crash during recovery and simply run the whole loop again.

Full-page-write records are the exception — they are pasted over the page regardless of pd_lsn. A page torn mid-write (8 KB spans two disk sectors) has an untrustworthy header, so comparing against its pd_lsn would be unsafe; stamping a whole known-good image is idempotent by construction. This is why WAL volume spikes right after each checkpoint, and why more frequent checkpoints inflate total WAL.

Visual Model

Picture WAL as a notary's append-only journal sitting in front of the ledger. Before a clerk edits the ledger (a data page), they write the intended change in the journal and wait for the ink to dry (fsync) — only then tell the customer "done" (the commit ack). Ledger pages update lazily. If the office burns down (a crash), you fetch the surviving ledger, find the last "books balanced & photographed" stamp (the redo point), and replay every journal entry after it — checking each page's "last-updated" mark (pd_lsn) to skip changes already reflected. Writing the journal is one fast sequential motion; editing ledger pages is slow flipping all over the book — so the bank commits thousands per second by making the customer wait only on the journal, and never loses a confirmed transaction.

Step through the WAL lifecycle below: the change lands in the log, the commit hardens it to disk before the data pages, a checkpoint catches the pages up and stamps a redo point, a crash strikes, and recovery replays the log forward.

Step 1 of N
WAL lifecycle — log first, data later, replay after crash WAL on disk — pg_wal (append-only, sequential) LSN → Data files (8 KB pages, disk) heap + index pages pages lag behind WAL time → WAL buffer (RAM) append LSN 150 — describes the change COMMIT ack — durable fsync WAL to disk, before any data page redo point checkpoint: flush dirty pages CRASH data pages behind the WAL head replay WAL forward from redo point → for each record after redo point: LSN > page.pd_lsn → APPLY, set pd_lsn LSN ≤ page.pd_lsn → SKIP (already there)

Loading…

Deeper — Edge Cases & Gotchas

Redo-only: there is no undo log

Postgres WAL is redo-only. Unlike systems with an undo log, atomicity's "undo" is not the log's job — it is delegated to MVCC and the commit log (clog). A ROLLBACK does not physically reverse anything: it simply flips the transaction to ABORTED in the clog. MVCC visibility then makes that transaction's tuples invisible to everyone instantly; the dead tuples are reaped later by VACUUM. Postgres flips the cost model — rollback is cheap, and you pay later in VACUUM — whereas an undo-log system makes rollback the expensive path (restoring before-images).

Why commit-fsync and checkpoint-fsync are different jobs

Both a commit and a checkpoint call fsync, but on different things and for different reasons. A commit fsyncs the WAL (one sequential file) to make the transaction durable. A checkpoint fsyncs the dirty data pages (scattered across heap and indexes) to bound recovery time and free old WAL. This is exactly why logging-first makes commits faster: at commit you harden one sequential file, not the many random pages a transaction touched — and group commit lets one WAL fsync harden many concurrent transactions' commit records at once.

Anti-pattern: "synchronous_commit=off risks database corruption, so never use it."
SET synchronous_commit = off;   -- high-volume ingestion
-- ... transactions return before their WAL is fsync'd ...
-- crash before the flush lands

The misconception is "risk of loss" collapsing into "risk of corruption." Async commit only moves the durability boundary into the past: you may lose the last fraction of a second of committed transactions — a consistent suffix — but never a partial or corrupt one. Recovery still replays WAL in strict LSN order and rolls back incomplete transactions, so every recovered transaction is whole; and async never relaxes the write-ahead ordering, so a data page can never outrun its WAL. Lost suffix, never corruption. (Contrast fsync=off or full_page_writes=off on non-atomic storage, which genuinely can corrupt.)

Anti-pattern: assuming more frequent checkpoints are "safer" for free.
checkpoint_timeout = 30s   -- aggressively frequent

Every checkpoint resets the full-page-write clock: the first modification of each page afterward dumps a whole 8 KB image into the WAL. Checkpointing more often means more first-touches, so WAL volume balloons and steady-state I/O rises — for a shorter recovery you did not necessarily need. The real levers (checkpoint_timeout, max_wal_size, checkpoint_completion_target) trade recovery speed and WAL retention against steady-state write amplification; tune them together, not blindly downward.

Beyond crash recovery — PITR & replication

The same log doubles as a change feed. PITR: archive each completed 16 MB segment (archive_command); a base backup plus WAL replay restores to any point in time ("three seconds before the bad DELETE"). Replication: a standby streams and replays the primary's WAL to stay in near-real-time sync; wal_level=logical adds row-level logical decoding. One consequence to watch: replication slots and a failing archive_command pin WAL in pg_wal, so it stops recycling and the directory grows without bound.

Test Yourself

You write the change twice (once to the WAL, once to the data page) yet commits get faster, not slower. Why?

A teammate wants synchronous_commit=off for high-volume ingestion but fears it can corrupt the database on a crash. What is the correct risk assessment?

During recovery, why does Postgres apply a WAL record only if its LSN > the page's pd_lsn — and why are full-page-write records applied differently?