cld-toys › Guided exercises › PostgreSQL

WAL and Crash Recovery

When COMMIT returns, your rows are not in the table. SIGKILL a running Postgres and watch it rebuild itself from the log — then turn one setting off and lose 35 transactions that had already reported success.


Concept

When COMMIT returns, your rows are not in the table file. They're in the write-ahead log — a sequential append-only journal — and the actual table page is still a dirty buffer in RAM that nobody has written down. The promise Postgres makes is narrower than "your data is saved": it is "the intent to save your data has been fsynced to a log, and I can reconstruct the table from that log after a crash."

This exercise kills a running Postgres with SIGKILL — no clean shutdown, no chance to flush anything — and then watches it rebuild itself from that log. You'll see committed-but-never-checkpointed rows come back, watch uncommitted rows get replayed onto disk and then be ignored anyway, and finish by turning one GUC off and losing 35 transactions that had already told the client they succeeded.


Mental model 1: where your row actually is

A single INSERT ... COMMIT moves data through four places. Only two of them are on disk, and they're not the two you'd guess:

PlaceWhat's thereSurvives kill -9?
The heap page in shared_buffers The real, final row, in the real table page. Marked dirty: memory disagrees with disk. No. RAM.
The WAL buffer A compact description of the change ("insert this tuple at offset 3 of block 0 of relation 16437"). No. RAM.
The WAL file on disk
pg_wal/…
The same description, appended sequentially and fsynced. This is what COMMIT waits for. Yes.
The heap file on disk
base/…
The table as the OS sees it — as of the last time somebody wrote those pages out. Yes, but it's stale, and that's fine.
The gap between the last two rows is the entire design Writing the WAL is one sequential append plus one fsync; writing the heap would be a scattered random write per dirty page. So Postgres makes the commit path pay only for the cheap one, and defers the expensive one indefinitely.

Two more terms close the loop:

TermWhat it is
LSN
log sequence number
A byte offset into the WAL, printed as 0/15AEA20. Every WAL record has one. Subtracting two LSNs gives you bytes, which is why you can do arithmetic on them in SQL.
Checkpoint Postgres flushes every dirty buffer to the heap files and records "as of LSN X, disk is caught up". X is the redo LSN.

Recovery is then a two-line algorithm: read the redo LSN out of pg_control, and replay every WAL record from there to the end of the log. Everything before the redo LSN is already on disk by definition; everything after it is in the WAL by definition. That's the whole mechanism.

last checkpoint crash redo LSN │ │ │ ────────┼────────────────────────────────────────────┤ │ │ heap on │◀── already flushed to disk ────┤ │ disk │ │ │ │ WAL │───────── replay every record ─────────────▶│ │ │ "redo starts at" "redo done at"

Mental model 2: synchronous_commit, and what each level costs you

Part 4 turns the knob that decides how hard COMMIT works before it answers. These are the levels, weakest last:

LevelCOMMIT returns after…What a kill -9 losesReach for it when…
remote_apply the standby has replayed the commit and it's visible to readers there nothing You load-balance reads onto replicas and a user must never write, redirect, and read their own write as missing. The most expensive setting: every commit waits a full round trip plus replay.
on
(default)
the local WAL is fsynced and the standby has flushed it, if you have synchronous standbys nothing The default, and correct for anything that represents money, identity, or a legal record. Non-negotiable for the system of record.
remote_write local fsync + standby has write()n the WAL, but not fsynced it nothing on a Postgres crash; data if the standby's OS crashes at the same moment as the primary Cross-region replicas where you'll accept a double-fault window to avoid paying the remote disk latency on every commit.
local the local WAL is fsynced. Ignores standbys entirely. nothing on this node Per-transaction escape hatch: a bulk import into a table you'd rather not stall on a slow replica, while everything else keeps waiting for it.
off …nothing. The commit record is in a memory buffer; the WAL writer will get to it, typically within 200 ms. every transaction committed in the last ~600 ms — after the client was told they succeeded Data you can regenerate: clickstream, metrics, session heartbeats, a cache warm-up. Crucially, it is not like fsync = off — it never corrupts anything, it only ever loses whole recent transactions.
Two properties to pin down before you touch it off is per-transactionSET LOCAL synchronous_commit = off inside one transaction, and it's the only one at risk. And off cannot corrupt your database: the WAL is still written in order, so recovery still ends at a consistent point. It just ends earlier than the client believed.

Setup

Two terminals, side by side. Everything below assumes you have Docker.

docker run --name pg-wal -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16
Note the missing --rm Unlike the other exercises, this container has to survive being killed and then be started again — that is the exercise. Clean it up by hand at the end.

Terminal A:

docker exec -it pg-wal psql -U postgres

Terminal B (a second, independent connection — needed from Part 2):

docker exec -it pg-wal psql -U postgres

In A, create the schema:

CREATE EXTENSION pageinspect;
CREATE EXTENSION pg_buffercache;
CREATE TABLE orders (id int PRIMARY KEY, item text);

Both extensions ship with the official image. pg_buffercache lets you see which pages are dirty in shared memory; pageinspect lets you read raw heap pages, which is how Part 3 catches Postgres red-handed.


Part 1 — a commit that is not on disk

What this part tests: mental model 1's claim that after COMMIT, the table page is still only in RAM. If that's true, there must be a moment where the rows are unquestionably committed and the heap buffer is unquestionably dirty.

Step 1 · a clean starting line
Session A
CHECKPOINT;
SELECT checkpoint_lsn, redo_lsn, pg_current_wal_lsn()
FROM pg_control_checkpoint();
checkpoint_lsn | redo_lsn | pg_current_wal_lsn ----------------+-----------+-------------------- 0/15AEA58 | 0/15AEA20 | 0/15AEAD0 (1 row)

redo_lsn is the number that matters — the point recovery would start from if the server died right now. Write it down. (Your LSNs will differ; only the differences between them are meaningful.)

Step 2 · commit three rows
Session A
INSERT INTO orders VALUES (1,'committed-a'),(2,'committed-b'),(3,'committed-c');

SELECT redo_lsn, pg_current_wal_lsn(),
       pg_current_wal_lsn() - redo_lsn AS wal_bytes_to_replay
FROM pg_control_checkpoint();

SELECT relblocknumber, isdirty FROM pg_buffercache
WHERE relfilenode = (SELECT relfilenode FROM pg_class WHERE relname = 'orders');
Predict: that is a fully committed transaction. Has redo_lsn moved — and is the table page on disk? Click to check.
redo_lsn | pg_current_wal_lsn | wal_bytes_to_replay -----------+--------------------+--------------------- 0/15AEA20 | 0/15B27B0 | 15760 (1 row) relblocknumber | isdirty ----------------+--------- 0 | t (1 row)

redo_lsn is unchanged — a COMMIT does not trigger a checkpoint. The WAL has grown by 15,760 bytes, and the only copy of your three rows that a reader could use is isdirty = t: block 0, in memory, disagreeing with the file on disk.

Committed, and not on disk. That is the normal steady state of a running Postgres, not an edge case.

15,760 bytes for three tiny rows? Almost all of it is full-page writes. The first time a page is modified after a checkpoint, Postgres copies the entire 8 KB page into the WAL, so that a torn write (an 8 KB page half-written when the power died) can be repaired rather than merely re-applied. pg_waldump shows two of them here as len (rec/tot): 60/7616 — a 60-byte record carrying a 7.5 KB page image. This is why WAL volume spikes right after every checkpoint, and why checkpointing more often makes a write-heavy server slower, not faster.

Part 2 — kill it

What this part tests: the recovery algorithm from the mental model — start at redo_lsn, replay to the end. You wrote redo_lsn down in Part 1; the log should print that exact number back at you.

Step 3 · leave a transaction hanging
Session B — write three more rows and do not commit:
BEGIN;
INSERT INTO orders VALUES (10,'uncommitted-x'),(11,'uncommitted-y'),(12,'uncommitted-z');
SELECT txid_current();
txid_current -------------- 745 (1 row)

Leave B sitting exactly there. (Session A's transaction was 744.)

Session A — note where the WAL ends, then walk away from psql:
SELECT pg_current_wal_lsn();
pg_current_wal_lsn -------------------- 0/15B5D98 (1 row)
Step 4 · SIGKILL the postmaster

From the host shell — no shutdown checkpoint, no buffer flush, no chance to run any code at all:

docker kill --signal=KILL pg-wal
docker start pg-wal
docker logs pg-wal | tail -8
Predict: six rows were written, three of them committed. What does the startup log say, and what comes back? Click to check.
LOG: database system was interrupted; last known up at 2026-07-22 15:13:13 UTC LOG: database system was not properly shut down; automatic recovery in progress LOG: redo starts at 0/15AEA20 LOG: invalid record length at 0/15B7A30: expected at least 24, got 0 LOG: redo done at 0/15B5D98 system usage: CPU: user: 0.00 s, system: 0.00 s LOG: checkpoint starting: end-of-recovery immediate wait LOG: database system is ready to accept connections

Read those three middle lines against your notes:

  • redo starts at 0/15AEA20 — character-for-character the redo_lsn you read in Step 1. Postgres found it in pg_control and began there.
  • redo done at 0/15B5D98 — character-for-character the pg_current_wal_lsn() you read in Step 3. It replayed right up to the last byte that existed when the process died.
  • invalid record length at 0/15B7A30: expected at least 24, got 0 is not an error, despite reading like one. It is how recovery knows where to stop: it walks forward until the next record header is garbage or zeroes, which is exactly what the tail of a WAL segment killed mid-stream looks like. Every crash recovery prints one.
Session A — reconnect:
SELECT * FROM orders ORDER BY id;
id | item ----+------------- 1 | committed-a 2 | committed-b 3 | committed-c (3 rows)

The three committed rows are back — reconstructed purely from the WAL, since Part 1 proved they were never written to the table file. The three uncommitted rows are gone. Session B is gone too; its connection died with the server, which for an open transaction is an implicit rollback.


Part 3 — the uncommitted rows are on disk anyway

What this part tests: how the uncommitted rows are gone. The obvious theory is that recovery skipped them. It didn't. This is the part worth slowing down for.

Step 5 · read the raw page
Session A
SELECT lp, t_xmin, t_xmax,
       t_infomask & 256 > 0 AS xmin_committed,
       t_infomask & 512 > 0 AS xmin_aborted
FROM heap_page_items(get_raw_page('orders', 0));
Predict: rows 10–12 are not visible to any query. Are they physically on page 0 of the table file? Click to check.
lp | t_xmin | t_xmax | xmin_committed | xmin_aborted ----+--------+--------+----------------+-------------- 1 | 744 | 0 | t | f 2 | 744 | 0 | t | f 3 | 744 | 0 | t | f 4 | 745 | 0 | f | t 5 | 745 | 0 | f | t 6 | 745 | 0 | f | t (6 rows)

Six tuples. The three "gone" rows are sitting right there, written by transaction 745 — the txid_current() session B printed before it died. And they're readable:

SELECT lp, t_xmin, t_data FROM heap_page_items(get_raw_page('orders',0)) WHERE lp > 3;
lp | t_xmin | t_data ----+--------+---------------------------------------- 4 | 745 | \x0a0000001d756e636f6d6d69747465642d78 5 | 745 | \x0b0000001d756e636f6d6d69747465642d79 6 | 745 | \x0c0000001d756e636f6d6d69747465642d7a (3 rows)

756e636f6d6d69747465642d78 is ASCII for uncommitted-x. Recovery replayed those inserts into the page just as faithfully as the committed ones.

Step 6 · ask the WAL why

From the host shell. 16437 is the table's relfilenode, from SELECT relfilenode FROM pg_class WHERE relname='orders':

docker exec -i pg-wal /usr/lib/postgresql/16/bin/pg_waldump \
  -p /var/lib/postgresql/data/pg_wal -s 0/15AEA20 -e 0/15B5D98 \
  | grep -E '16437|Transaction'
Predict: six inserts went into that page. How many records does the WAL hold for them? Click to check.
tx: 744, lsn: 0/015B2590, desc: INSERT+INIT off: 1, blkref #0: rel 1663/5/16437 blk 0 tx: 744, lsn: 0/015B2678, desc: INSERT off: 2, blkref #0: rel 1663/5/16437 blk 0 tx: 744, lsn: 0/015B2700, desc: INSERT off: 3, blkref #0: rel 1663/5/16437 blk 0 tx: 744, lsn: 0/015B2788, desc: COMMIT 2026-07-22 15:13:13.650095 UTC tx: 745, lsn: 0/015B3E20, desc: INSERT off: 4, blkref #0: rel 1663/5/16437 blk 0 tx: 745, lsn: 0/015B3EB0, desc: INSERT off: 5, blkref #0: rel 1663/5/16437 blk 0 tx: 745, lsn: 0/015B3F40, desc: INSERT off: 6, blkref #0: rel 1663/5/16437 blk 0

Six INSERT records. One COMMIT record. Transaction 745 has three inserts, no commit, and no abort — nothing ever got the chance to write one.

The actual durability rule It is not "uncommitted changes aren't logged" — they're logged, replayed, and land on disk. It is: a transaction is committed if and only if its commit record is in the WAL. Recovery restores physical bytes; visibility is decided afterward by MVCC, which finds xid 745 in neither the commit log nor the list of in-progress transactions, concludes it aborted, and stamps xmin_aborted on those tuples the first time anyone looks. Later, VACUUM collects them. Rollback after a crash costs nothing because there is nothing to undo — the tuples are simply never visible.

That is why the WAL can be written before the outcome is known, and why an aborted transaction is as cheap as a committed one. It's also the difference from an undo-log database like InnoDB, which must actively roll back uncommitted work during recovery.


Part 4 — the commit that lies

What this part tests: mental model 2's bottom row. synchronous_commit = off moves exactly one thing — the fsync — off the commit path. The claim is that the client is then told "success" about transactions that are still only in RAM.

Step 7 · the durability watermark
Session A — compare the two settings without crashing anything:
SET synchronous_commit = on;
INSERT INTO orders VALUES (30,'sync-on');
SELECT pg_current_wal_insert_lsn() AS inserted,
       pg_current_wal_flush_lsn()  AS flushed_to_disk,
       pg_current_wal_insert_lsn() - pg_current_wal_flush_lsn() AS unflushed_bytes;

SET synchronous_commit = off;
INSERT INTO orders VALUES (31,'sync-off');
-- ...same SELECT again
Predict: both INSERTs return INSERT 0 1. Is there any observable difference at all? Click to check.
synchronous_commit = on
inserted | flushed_to_disk | unflushed_bytes -----------+-----------------+----------------- 0/15BD1C0 | 0/15BD1C0 | 0
synchronous_commit = off
inserted | flushed_to_disk | unflushed_bytes -----------+-----------------+----------------- 0/15BD270 | 0/15BD1C0 | 176

No race, no timing trick: INSERT 0 1 came back, and 176 bytes of WAL — including that transaction's own commit record — are sitting in a memory buffer. pg_current_wal_flush_lsn() is the durability watermark, and your commit is above it.

Step 8 · make it cost something
Session A
CREATE TABLE async_log (id serial PRIMARY KEY, note text);
CHECKPOINT;

From the host shell, generate 400,000 single-row autocommit inserts and feed them in:

python3 -c "
print('SET synchronous_commit = off;')
for i in range(1,400001): print(\"INSERT INTO async_log(note) VALUES ('r%d');\" % i)
" > bulk.sql
docker exec -i pg-wal psql -U postgres -t < bulk.sql > acked.log

After about six seconds, from another terminal, kill it. Every line in acked.log is a transaction Postgres told the client it had committed.

docker kill --signal=KILL pg-wal
grep -c "INSERT 0 1" acked.log
docker start pg-wal
docker exec -i pg-wal psql -U postgres -c "SELECT count(*), max(id) FROM async_log;"
Predict: how many of the acknowledged commits come back? Click to check.
197138 <- commits acknowledged to the client count | max --------+-------- 197103 | 197103 (1 row)

35 transactions that returned success no longer exist. Not corrupted, not partially applied — ids 197104 through 197138 are simply not in the table, and the table is perfectly consistent without them. If those had been payments, the client-side ledger and the database now disagree, and nothing anywhere logged an error.

Step 9 · the control

Repeat Step 8 exactly, with synchronous_commit = on. Same six seconds, same kill.

Predict: how many lost this time — and how far did it get? Click to check.
46783 <- commits acknowledged to the client count | max -------+------- 46784 | 46784 (1 row)

Zero lost — and one row more than the client saw acknowledged. That extra row is the honest direction of the error: transaction 46,784 was fsynced and durable, and the server died before its INSERT 0 1 made it back through the socket. A client that retries it will find it already there. Losing an acknowledgement is a recoverable problem; losing an acknowledged transaction is not.

Also read the other number in that output. In the same six seconds, off finished 197,138 commits and on finished 46,783 — 4.2×. That throughput is what you are buying with those 35 transactions.

Step 10 · where the 4.2× comes from
Session A — 5,000 commits each way, with SELECT pg_stat_reset_shared('wal'); in between:
SELECT wal_records, wal_write, wal_sync FROM pg_stat_wal;
Predict: does synchronous_commit = off write less WAL? Click to check.
wal_records | wal_write | wal_sync sync on ------------+-----------+---------- 15195 | 5002 | 5002 sync off 15193 | 111 | 4

Identical WAL volume — 15,195 vs 15,193 records for the same work. The setting doesn't reduce logging by a single byte. What changes is wal_sync: 5,002 fsyncs versus 4. One durable point per commit, versus one every 200 ms because the WAL writer happened to wake up. Wall clock for the batch: 1.22 s vs 0.58 s.


Part 5 — what a checkpoint actually buys

What this part tests: the other half of the checkpoint's job. It doesn't make anything more durable — Parts 2 and 4 were durable via WAL alone — so what is it for?

Step 11 · checkpoint, then crash immediately
Session A — run the 5,000-insert batch again, then:
CHECKPOINT;
SELECT redo_lsn, pg_current_wal_lsn(),
       pg_current_wal_lsn() - redo_lsn AS to_replay
FROM pg_control_checkpoint();
redo_lsn | pg_current_wal_lsn | to_replay -----------+--------------------+----------- 0/4D3FF00 | 0/4D3FFB0 | 176 (1 row)
docker kill --signal=KILL pg-wal && docker start pg-wal
Predict: how much work does recovery do this time? Click to check.
LOG: database system was not properly shut down; automatic recovery in progress LOG: redo starts at 0/4D3FF00 LOG: redo done at 0/4D3FF38 system usage: CPU: user: 0.00 s, system: 0.00 s, elapsed: 0.00 s

56 bytes replayed, 0.00 s. Compare the crash in Part 4, where nothing had been checkpointed for a while:

LOG: redo starts at 0/2056E90 LOG: redo done at 0/427FFC0 system usage: CPU: user: 0.12 s, system: 0.01 s, elapsed: 0.13 s

35 MB of WAL, 0.13 s of replay. Same durability guarantee in both cases — no committed transaction was lost either time.

A checkpoint buys downtime, not durability The only thing it changed is how long you are down afterward, and that's the trade the max_wal_size / checkpoint_timeout knobs control: checkpoint often and pay steady random I/O plus full-page writes forever, or checkpoint rarely and pay it all at once as downtime after a crash. On a busy production server that 0.13 s is minutes.

What you should see

A COMMIT that returns while pg_buffercache still reports the table page as isdirty = t — committed and not on disk. A SIGKILLed server that comes back printing redo starts at 0/15AEA20, the exact redo LSN you read before the crash, and redo done at 0/15B5D98, the exact WAL position you read before the crash — and hands you back three rows that never touched the table file. Six tuples on page 0 of that table, half of them belonging to a transaction that never committed, replayed onto disk and rendered invisible not by recovery but by the absence of a single 34-byte COMMIT record in the WAL. And, with synchronous_commit = off, 35 transactions that returned success to the client and no longer exist.

Why

Because the only cheap durable write is a sequential append. A commit that had to place rows into their final positions in the table file would mean a random write per page touched, plus an fsync, on the latency path of every transaction. The WAL replaces all of that with one append to one file and one fsync, and buys back the correctness by promising that the table can always be reconstructed from the log. Checkpointing is then just amortization: it does the expensive scattered writes in the background, on its own schedule, and moves the redo LSN forward so the log doesn't have to be replayed from the beginning of time.

Given that, everything else in this exercise is a corollary. Recovery starts at the redo LSN because that's where disk stopped being trustworthy (Part 2). It replays uncommitted changes because it is a physical byte-level redo that doesn't know or care about transaction outcomes, and because MVCC will filter them out afterward at no cost (Part 3) — a Postgres transaction is committed precisely when its commit record is in the log, and rollback is therefore free. Full-page writes exist because fsync guarantees ordering, not atomicity, and an 8 KB page can still be torn by a power failure mid-write (Part 1).

And synchronous_commit = off is the one setting that breaks the promise on purpose. It doesn't skip the log or reorder it — recovery still stops at a perfectly consistent point, which is why it cannot corrupt anything. It just moves the fsync off the commit path, so the point where recovery stops can be earlier than what the client was told. That is a coherent trade for clickstream data and an incoherent one for money, and the 4.2× throughput is exactly the size of the bribe.


Go deeper

Sources: PostgreSQL Docs: Reliability and the Write-Ahead Log — its reliability section covers what fsync does and does not guarantee about disk caches · synchronous_commit · pg_waldump


Cleanup

docker stop pg-wal && docker rm pg-wal