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_lsnis 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_lsnis untrustworthy. synchronous_commit- The durability/latency dial.
on(default) makes COMMIT wait for the WALfsync→ zero committed-data loss.offreturns 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;logicaladds 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.
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.
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.)
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.
See Also
ACID Properties The why behind WAL: redo powers Durability and the commitfsync happens before the ack. This entry is the how.
How Data Is Stored on Disk — Heap Files, Pages & Slots
Where pd_lsn lives in the page header, and the torn-page hazard that full-page writes defend against.
MVCC — How Postgres Implements Multi-Version Concurrency Control
Why WAL is redo-only: ROLLBACK is a clog flip made instant by MVCC visibility, not a log reversal — MVCC does the undo WAL doesn't.
Buffer Pool / Page Cache — How a DB Manages Memory
Where the write-ahead rule is enforced: a dirty buffer can't be evicted until its WAL is flushed; checkpoints flush dirty buffers from the pool.
Table Bloat & Autovacuum — Reclaiming What MVCC Leaves Behind
Replication slots and failed archiving pin WAL in pg_wal; slots also pin xmin, feeding the bloat this entry cleans up.
Isolation Levels & Read Anomalies
The same MVCC snapshot machinery; recovery's consistent-prefix guarantee echoes snapshot consistency at a point in time.
Sources consulted
- PostgreSQL Documentation — Reliability and the Write-Ahead Log — fetched 2026-06-30
- PostgreSQL Documentation — WAL Configuration — fetched 2026-06-30
- The Internals of PostgreSQL §9.1 — WAL / LSN — fetched 2026-06-30
- The Internals of PostgreSQL §9.8 — Database Recovery — fetched 2026-06-30
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?