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.
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.
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:
| Place | What's there | Survives 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. |
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:
| Term | What 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.
synchronous_commit, and what each level costs youPart 4 turns the knob that decides how hard COMMIT works before it answers. These are the levels, weakest last:
| Level | COMMIT returns after… | What a kill -9 loses | Reach 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. |
off is per-transaction — SET 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.
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
--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.
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.
CHECKPOINT;
SELECT checkpoint_lsn, redo_lsn, pg_current_wal_lsn()
FROM pg_control_checkpoint();
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.)
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');
redo_lsn moved — and is the table page on disk? Click to check.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.
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.
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.
BEGIN;
INSERT INTO orders VALUES (10,'uncommitted-x'),(11,'uncommitted-y'),(12,'uncommitted-z');
SELECT txid_current();
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();
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
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.SELECT * FROM orders ORDER BY id;
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.
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.
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));
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;
756e636f6d6d69747465642d78 is ASCII for uncommitted-x. Recovery replayed those inserts into the page just as faithfully as the committed ones.
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'
Six INSERT records. One COMMIT record. Transaction 745 has three inserts, no commit, and no abort — nothing ever got the chance to write one.
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.
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.
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
INSERTs return INSERT 0 1. Is there any observable difference at all? Click to check.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.
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;"
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.
Repeat Step 8 exactly, with synchronous_commit = on. Same six seconds, same kill.
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.
SELECT pg_stat_reset_shared('wal'); in between:
SELECT wal_records, wal_write, wal_sync FROM pg_stat_wal;
synchronous_commit = off write less WAL? Click to check.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.
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?
CHECKPOINT;
SELECT redo_lsn, pg_current_wal_lsn(),
pg_current_wal_lsn() - redo_lsn AS to_replay
FROM pg_control_checkpoint();
docker kill --signal=KILL pg-wal && docker start pg-wal
56 bytes replayed, 0.00 s. Compare the crash in Part 4, where nothing had been checkpointed for a while:
35 MB of WAL, 0.13 s of replay. Same durability guarantee in both cases — no committed transaction was lost either time.
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.
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.
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.
fsync = off (in postgresql.conf, requires a restart) and repeat Part 4. This is the genuinely dangerous one, and the contrast is the point: synchronous_commit = off loses recent transactions, fsync = off lets the OS reorder writes and can leave you with a corrupt cluster that won't start. Postgres will warn you at startup.SIGKILL only the checkpointer (pkill -9 -f checkpointer inside the container) instead of the postmaster. The postmaster notices a child died uncleanly and takes the whole cluster down into recovery on purpose — one crashed backend can have corrupted shared memory, so nothing in it can be trusted.pg_waldump across an UPDATE and a DELETE instead of an INSERT. There is no "delete the row" record type — you'll see Heap/DELETE setting xmax on the existing tuple, and Heap/UPDATE writing a new tuple plus a forward pointer. The WAL is a log of page edits, not of SQL statements, which is what makes physical replication possible.distance= and estimate= figures in the checkpoint-complete log lines while running Part 4's bulk load. That's the feedback loop Postgres uses to pace checkpoint I/O against checkpoint_completion_target.pg_basebackup, then restore with a recovery_target_time partway through the log and watch recovery stop early on purpose.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
docker stop pg-wal && docker rm pg-wal