chaowen.← Back to archive

Databases / storage deep dive

PostgreSQL WAL: why COMMIT is a log flush, not a data-page write

A database can acknowledge a committed transaction while the table page containing its rows is still only in memory. PostgreSQL makes that safe with write-ahead logging: flush the description of the change first, then let dirty data pages reach disk later.

Databases / storage deep dive · write-ahead logging, commit durability, checkpoints, crash recovery, and torn-page protection

2026-08-23

The concrete problem

Suppose an application transfers $10 from Alice to Bob. It sends two UPDATEs, receives COMMIT, and tells the customer the transfer succeeded. A power loss arrives before PostgreSQL has written the modified table pages from RAM to storage. If those pages were the only record of the change, the committed transfer would vanish—or, worse, one update might survive while the other does not.

Writing every changed table page synchronously at commit would make small transactions wait on random data-page I/O. PostgreSQL instead separates two jobs:

Core invariant: before a dirty data page is flushed, the WAL containing the change represented by that page must already be durable. A transaction can commit once its commit record—and the preceding WAL records it depends on—have reached durable storage. The data page itself may still be dirty.

The shape of the solution

PostgreSQL keeps a log sequence number (LSN) for positions in the WAL stream. A modified page stores the LSN of the WAL record that first made its current contents necessary. When PostgreSQL is about to write that page, it makes sure the WAL has reached at least that LSN.

client: UPDATE ...; COMMIT │ ▼ heap/index page in shared_buffers ── dirty, not necessarily on disk │ page LSN points into WAL │ ├──────────────► WAL buffers ──► pg_wal segment files ──► fsync │ durable WAL flush ▼ later data-page write crash ──► startup reads checkpoint + WAL ──► redo records ──► consistent pages │ └─ committed transaction is visible; incomplete work is undone/ignored

The log is not a SQL audit trail. Most WAL records are physical or physiological descriptions such as “insert this tuple into this heap page” or “update these index bytes.” Their purpose is fast crash recovery and replication, not human-readable history.

What happens during an update and commit

  1. Find a buffer. The access method pins a relation page in shared_buffers, reading it from storage if necessary.
  2. Change memory. The heap or index code modifies the page, marks the buffer dirty, and creates a WAL description. The page header receives the resulting record's LSN through the buffer/page APIs.
  3. Insert WAL. PostgreSQL assembles record fragments in WAL insertion machinery and reserves a position in the shared WAL buffers. The record can contain a resource-manager ID, block references, redo info, and backup-image data when needed.
  4. Write the commit record. Transaction code records the transaction's commit metadata and obtains a commit-record LSN. The commit path remembers the latest WAL position it needs.
  5. Flush to the requested durability level. With the usual synchronous_commit=on, PostgreSQL waits for WAL through that LSN to be written and synchronized by the WAL writer/backend before returning success.
  6. Write data later. A checkpointer or buffer writer eventually writes the dirty heap/index page. The page does not need to be written again at every commit because recovery can reconstruct it from WAL.

Group commit is the important performance trick hiding inside step 5. Concurrent transactions can share one WAL flush: once the flush reaches the furthest requested LSN, earlier committers are durable too. This turns many small sync operations into fewer batched ones, at the cost of a little queueing.

Source-level landmarks

Names move across PostgreSQL releases, but these open-source modules expose the durable path clearly. Read them as a map of responsibilities rather than as a stable extension API.

AreaLandmarkRole
Transaction commitsrc/backend/access/transam/xact.cBuilds and records transaction commit/abort state; RecordTransactionCommit() is the useful entry point to follow.
Record assemblyxloginsert.cCollects registered data and block references, chooses a resource manager, and inserts WAL records.
WAL insertion/flushxlog.cCoordinates WAL buffers, segment writes, flush positions, checkpoints, and recovery; follow XLogFlush() and XLogWrite().
WAL record typessrc/backend/access/rmgr/Resource managers implement redo routines for heap, btree, database, transaction, and other record families.
RecoveryStartupXLOG() and xlogreader.cFinds a checkpoint, reads records with XLogReaderState, and dispatches redo until the end of valid WAL.

The central types are deliberately low-level. XLogRecPtr is an LSN into the WAL stream. XLogRecord carries record length, previous-record linkage, transaction ID, and resource-manager info. XLogReaderState holds the reader's segment/buffer state while recovery or inspection walks records. Page headers carry a page LSN, so a page write can compare its required WAL position with the WAL flush position.

Checkpoints and recovery time

A checkpoint writes enough dirty pages and records a checkpoint position so recovery does not need to scan WAL from the beginning of time. After a crash, startup begins near the last valid checkpoint and replays WAL records forward. A clean shutdown can avoid crash recovery; an unclean one leaves a signal that recovery is required.

Checkpoints trade recovery time against foreground I/O. Frequent checkpoints reduce the amount of WAL to replay but can create write bursts. max_wal_size, checkpoint_timeout, checkpoint_completion_target, and the workload's dirty-page rate shape that behavior. The checkpointer is not a commit barrier: commit durability comes from WAL flush, while checkpoints bound recovery work and make old WAL removable.

Why a page LSN prevents the wrong write order

Assume a dirty page contains a change whose WAL record ends at LSN 500. If the page reaches disk while durable WAL ends at LSN 400, a crash could leave a page that claims an update the log cannot redo. Before writing the page, PostgreSQL waits until WAL is flushed through at least 500. If the page write tears or is lost afterward, recovery still has the record needed to repair it.

Important settings and their actual guarantees

SettingWhat it changesFailure implication
synchronous_commitHow far commit waits: local WAL flush, or configured synchronous standbys with remote_write, remote_flush, or remote_apply.off lets the client receive success before local WAL flush; a crash can lose recently acknowledged transactions, but PostgreSQL preserves crash consistency rather than exposing half a committed transaction.
fsyncWhether PostgreSQL tries to issue the required flushes to make changes durable.Turning it off can improve benchmarks while risking unrecoverable corruption after a crash. It is not a production durability optimization.
full_page_writesAfter a checkpoint, the first change to a page can put a full-page image in WAL.This protects against torn pages when storage writes sectors non-atomically. It costs WAL volume; disabling it requires storage guarantees and careful operational control.
wal_sync_methodSelects the platform mechanism used to synchronize WAL, such as fdatasync or open_datasync.The best choice is platform/storage dependent. A fast-looking call is not a guarantee if the device, virtual disk, or controller lies about flush completion.
wal_compressionCompresses eligible full-page images in WAL.Can reduce write bandwidth at CPU cost; it does not change the ordering contract.

There are two separate questions that are often collapsed into “is it durable?”: has PostgreSQL issued a successful synchronization request, and does the storage stack honor that request across power loss? A database cannot repair a cloud volume, RAID controller, filesystem, or VM hypervisor that falsely acknowledges flushes. Validate the whole stack, not only the SQL setting.

Failure modes and debugging

Useful observability includes pg_current_wal_lsn(), pg_wal_lsn_diff(), pg_stat_wal, pg_stat_bgwriter, checkpoint log lines, and replication views such as pg_stat_replication. For a forensic view, the PostgreSQL distribution's pg_waldump can decode records from a WAL directory. Preserve the original WAL and server logs before attempting cleanup.

Security and operational implications

WAL is part of the database's confidentiality and recovery boundary. It can contain old row images, index keys, full-page images, and changes that an application later “deletes.” Protect pg_wal, archived WAL, base-backup storage, replication connections, and diagnostic copies with the same access controls as the primary data directory.

WAL archiving also changes the incident story: an attacker who gains write access to an archive location may be able to corrupt the recovery chain or exhaust storage, while an attacker who reads it may recover historical data. Validate archive continuity, restrict restore commands, encrypt archives in transit and at rest, and test point-in-time recovery. Do not treat a successful COMMIT as proof that the only copy of sensitive data is in the current table.

Compact reproducible lab

Goal: observe a commit advance the WAL position, inspect the WAL directory, force a checkpoint, and see that a commit is not the same operation as writing every table page.

Prerequisites: Docker or Podman with the official PostgreSQL 18 image available, about 1 GB of disposable disk, and a shell. The commands use a temporary host port and remove the container at the end.

LAB="pg-wal-lab-$$"
docker run --name "$LAB" \
  -e POSTGRES_PASSWORD=lab \
  -e POSTGRES_DB=lab \
  -d postgres:18

until docker exec "$LAB" pg_isready -U postgres -d lab >/dev/null 2>&1; do sleep 1; done

docker exec -i "$LAB" psql -U postgres -d lab <<'SQL'
CREATE TABLE ledger(id integer PRIMARY KEY, amount integer NOT NULL);
SELECT pg_current_wal_lsn() AS before_insert;
BEGIN;
INSERT INTO ledger VALUES (1, 100), (2, -100);
COMMIT;
SELECT pg_current_wal_lsn() AS after_commit,
       pg_walfile_name(pg_current_wal_lsn()) AS wal_segment;
SELECT name, setting FROM pg_settings
 WHERE name IN ('synchronous_commit', 'fsync', 'full_page_writes');
CHECKPOINT;
SQL

docker exec "$LAB" sh -lc 'du -h "$PGDATA"/pg_wal; pg_waldump -p "$PGDATA"/pg_wal -n 12 | tail -40'
docker rm -f "$LAB" >/dev/null

Expected result: the second LSN is later than the first, the default settings show local synchronous commit, and pg_waldump prints transaction, heap, and checkpoint records. The checkpoint may write dirty pages, but it is a separate event after the commit.

Variation 1: run a second container with -c synchronous_commit=off, insert a row, and repeatedly stop it immediately after the client receives success. On restart, the row may be absent because the commit was acknowledged before the WAL flush; the database should still recover to a consistent state. This is probabilistic, so do not use it as a correctness test.

Variation 2: run docker exec "$LAB" psql -U postgres -d lab -c "SELECT * FROM pg_stat_wal;" before and after a bulk insert. Compare wal_bytes, wal_records, and sync timing. Then repeat with full_page_writes=off only in a disposable experiment; the lower WAL volume is not evidence that the setting is safe for your storage stack.

Environment result: this environment has Docker but no PostgreSQL client/server binaries and no cached postgres:18 image. The lab was not executed because pulling an image would change the environment and the image was unavailable locally. To run it, pull the image on a disposable host, execute the commands, and if the image exposes pg_waldump at a different path, run docker exec "$LAB" sh -lc 'command -v pg_waldump; echo "$PGDATA"' and substitute the reported paths. A no-container fallback is to install PostgreSQL locally and run the same SQL with psql, then use the local pg_waldump against its pg_wal directory.

Production checklist

  1. Keep fsync=on, full_page_writes=on, and a tested wal_sync_method unless a storage specialist has documented a stronger equivalent.
  2. Choose synchronous_commit from the business loss budget: local durability, remote flush, or remote apply are different promises.
  3. Monitor WAL generation, sync time, checkpoint behavior, archive failures, replication lag, slots, and free space on the WAL filesystem.
  4. Test crash recovery and point-in-time restore with the same storage and archive path used in production.
  5. Protect current and archived WAL as sensitive database data; limit restore and replication privileges.

The mental model to keep is simple: COMMIT makes a log record durable at the configured level. It does not synchronously paint every modified table page onto storage. That separation is why PostgreSQL can make small commits durable without turning every transaction into a random-write storm—and why the WAL, its flush path, and its recovery tests deserve first-class operational attention.

Primary sources