Expedition. Free, on-demand.

Technical tracks for practitioners, outcomes for leaders.

How WAL Works

If Postgres crashes mid-transaction (power failure, kernel panic, out-of-memory kill), how does it make sure committed data isn't lost? WAL: Write-Ahead Logging.

The rule is simple: every change is written to the WAL before it's written to the data files. When a transaction commits, Postgres flushes its WAL records to disk. The data pages themselves get written later, lazily, in the background. If the system crashes before those pages hit disk, Postgres replays the WAL on startup and reconstructs the missing changes.

This works because WAL writes are sequential, appended to the end of a log file. Sequential I/O is much faster than the random I/O needed to update data pages scattered across the heap. Every database has some kind of transaction log, but in Postgres the same WAL also drives replication and point-in-time recovery.

WAL Segments and LSNs

WAL isn't one ever-growing file. It's split into segments, 16 MB each by default, stored in the pg_wal/ directory inside your data directory. Each segment's name is built from the timeline ID and the segment's position in the WAL stream, in hexadecimal.

Your current position in the WAL stream is a Log Sequence Number (LSN), a 64-bit byte position written as two hex numbers separated by a slash:

-- Where are we in the WAL stream right now?
SELECT pg_current_wal_lsn();
 pg_current_wal_lsn
--------------------
 0/3A8B9D0

You can map an LSN to the WAL file that contains it:

-- Which WAL file contains this LSN?
SELECT pg_walfile_name(pg_current_wal_lsn());
     pg_walfile_name
--------------------------
 000000010000000000000003

That name is three 8-character hex chunks: timeline 00000001, then 00000000 and 00000003, which together identify the segment. The timeline changes on recovery or promotion (more on that below).

LSNs are also handy for measuring things. Subtract two of them with pg_wal_lsn_diff() and you get bytes of WAL, which is how you measure replication lag or how much WAL a batch job generated.

Checkpoints

WAL alone doesn't cap how much work crash recovery has to do. That's the job of checkpoints.

A checkpoint flushes all dirty pages from shared buffers to the data files, then writes a checkpoint record to the WAL. Once a checkpoint completes, WAL before it isn't needed for crash recovery anymore, so those segments can be recycled (after they've been archived, if archiving is on).

Checkpoints are triggered by time or by WAL volume, whichever comes first.

On Postgres 17 and later, checkpoint stats live in pg_stat_checkpointer:

-- Postgres 17+
SELECT
  num_timed,
  num_requested,
  write_time,
  sync_time
FROM pg_stat_checkpointer;

On Postgres 16 and earlier, the same numbers are in pg_stat_bgwriter as checkpoints_timed, checkpoints_req, checkpoint_write_time, and checkpoint_sync_time.

num_timed counts checkpoints triggered by checkpoint_timeout. num_requested counts ones triggered by hitting max_wal_size (or run manually). If requested checkpoints are a big share of the total, you're generating more WAL between checkpoints than max_wal_size allows, and it's probably time to raise it.

WAL Statistics

Postgres tracks WAL activity in the pg_stat_wal view:

SELECT
  wal_records,
  wal_fpi,
  wal_bytes,
  pg_size_pretty(wal_bytes) AS wal_written,
  wal_buffers_full,
  stats_reset
FROM pg_stat_wal;
 wal_records |  wal_fpi  |   wal_bytes   | wal_written | wal_buffers_full |          stats_reset
-------------+-----------+---------------+-------------+------------------+-------------------------------
    48293012 |   1204501 |  6834019328   | 6364 MB     |             1204 | 2026-10-01 00:00:00.000000+00

wal_fpi is the count of full-page images: the first time a page is modified after a checkpoint, Postgres writes the whole page into WAL to protect against torn writes. Lots of FPIs is one reason WAL volume jumps right after each checkpoint, and another reason not to checkpoint too often.

wal_buffers_full counts how often WAL had to be written out because the WAL buffers filled up. If it's climbing steadily, wal_buffers may be too small for your write rate.

On Postgres 14 to 17, pg_stat_wal also has wal_write, wal_sync, wal_write_time, and wal_sync_time. Postgres 18 moved WAL write and sync I/O stats into pg_stat_io (look for object = 'wal'), so those columns are gone from pg_stat_wal there.

WAL Configuration

A handful of postgresql.conf settings control WAL. Getting them right matters for both performance and recoverability.

wal_level

This is the big one. It decides how much information goes into the WAL:

SHOW wal_level;
 wal_level
-----------
 replica
LevelWhat it recordsUse case
minimalOnly enough for crash recoveryStandalone servers with no replication or archiving
replicaEnough for streaming replication and archiving (default)Most production deployments
logicalAdds information needed to decode row-level changesLogical replication, CDC

Changing wal_level requires a restart. If you need replication or point-in-time recovery (and you almost certainly do), leave it at replica or higher.

Checkpoint Tuning

SHOW checkpoint_timeout;
SHOW max_wal_size;
SHOW min_wal_size;
SHOW checkpoint_completion_target;
 checkpoint_timeout | max_wal_size | min_wal_size | checkpoint_completion_target
--------------------+--------------+--------------+-----------------------------
 5min               | 1GB          | 80MB         | 0.9

checkpoint_timeout (default 5 minutes) is the maximum time between automatic checkpoints. Longer intervals mean more WAL to replay after a crash but fewer checkpoint I/O spikes.

max_wal_size (default 1 GB) triggers a checkpoint when this much WAL has accumulated since the last one. It's a soft limit, so WAL can go past it during write bursts or when archiving or a replication slot is holding segments.

min_wal_size (default 80 MB) is how much old WAL Postgres keeps around for reuse instead of deleting it, which saves the overhead of constantly creating new files.

checkpoint_completion_target (default 0.9) spreads checkpoint writes over that fraction of the checkpoint interval. With a 5-minute checkpoint_timeout, writes are spread over about 4.5 minutes instead of being flushed all at once.

For write-heavy workloads, raising max_wal_size to something like 4 to 8 GB and checkpoint_timeout to 15 to 30 minutes cuts checkpoint frequency (and full-page image volume). The tradeoff is longer crash recovery.

WAL Compression

SHOW wal_compression;

When it's set to on (pglz) or, on Postgres 15 and later, lz4 or zstd, Postgres compresses full-page images in WAL. That can cut WAL volume a lot on workloads heavy in FPIs, at the cost of some CPU. On I/O-bound systems it's usually a net win. Start with lz4.

Snowflake Postgres handles WAL archiving and backups for you, so point-in-time recovery is available without setting up archive_command or base backups yourself. The concepts below are still worth knowing, because they explain what a restore point actually is.

WAL Archiving and Point-in-Time Recovery

WAL segments normally get recycled after a checkpoint and the old data is gone. If you archive those segments first, you can combine them with a base backup to restore your database to any point in time. This is the foundation of Postgres disaster recovery.

Enabling WAL Archiving

# postgresql.conf
archive_mode = on
archive_command = 'cp %p /archive/wal/%f'

With archive_mode on, Postgres runs archive_command for each completed WAL segment. %p is the full path to the segment file and %f is just the filename. The command has to return exit code 0 on success. If it fails, Postgres keeps the segment and retries, which means a broken archive command will fill up pg_wal/ over time. Monitor pg_stat_archiver for failures.

In production, don't use cp. Use a tool built for this, like pgBackRest or WAL-G, that ships WAL to durable object storage (S3, GCS, Azure) with compression, verification, and retention handling. A raw upload command works as an example:

archive_command = 'aws s3 cp %p s3://my-bucket/wal/%f'

Postgres 15 and later also support archive_library for archive modules, as an alternative to a shell command.

The Backup + WAL = PITR Equation

A base backup is a physical copy of the data directory at a moment in time. On its own, it restores to roughly that moment. Combined with archived WAL, it restores to any moment between the backup and the most recent archived WAL.

Base Backup (taken Monday 2am)
  + Archived WAL segments (Monday 2am to Wednesday 4:37pm)
  = Restore to any second in that window

A copy of the data directory without the matching WAL is not a backup. If you snapshot the filesystem without guaranteeing WAL continuity, you have no guarantee of consistency, and no way to recover to a specific point.

Creating a Base Backup

pg_basebackup -D /backups/base_2026_10_15 -Ft -z -P -X stream

-X stream includes the WAL generated during the backup, so the backup is self-contained. Archived WAL from that point forward extends your recovery window. (Postgres 17 also added incremental backups with pg_basebackup --incremental and pg_combinebackup.)

Point-in-Time Recovery Workflow

Say someone ran an accidental DROP TABLE at 2026-10-15 14:32:00. To restore to just before it:

  1. Restore the most recent base backup taken before that time
  2. Configure recovery to stop at the target time
  3. Start Postgres, which replays WAL up to the target and stops
# postgresql.conf (or postgresql.auto.conf during recovery)
restore_command = 'cp /archive/wal/%f %p'
recovery_target_time = '2026-10-15 14:31:55'
recovery_target_action = 'promote'

Create a recovery.signal file in the data directory, then start Postgres. It replays WAL from the base backup through 14:31:55 and then promotes to a normal read-write server.

Timeline IDs

Every time recovery ends and the server is promoted, Postgres starts a new timeline, bumping the timeline ID used in WAL filenames from then on. That keeps WAL written before and after the recovery from getting mixed up.

Timeline history files (for example 00000002.history) record when and where each new timeline branched off. You need them to figure out which WAL sequence to follow when your history includes several recoveries or standby promotions.

A higher timeline number doesn't automatically mean "more recent data." It means "a different branch." When planning PITR, you need to know which timeline has the data you want.

WAL and Replication

WAL isn't only for crash recovery. Postgres replication is built directly on it: standbys receive and replay the same WAL stream a recovering server would.

Streaming Replication

With streaming replication, a standby connects to the primary and receives WAL records continuously over a regular Postgres connection. It replays them as they arrive and usually stays within seconds (or less) of the primary.

What to check on the primary:

-- wal_level must be replica or logical
SHOW wal_level;

-- Maximum concurrent replication connections
SHOW max_wal_senders;

-- Extra WAL kept around for standbys that fall behind
SHOW wal_keep_size;
 wal_level | max_wal_senders | wal_keep_size
-----------+-----------------+---------------
 replica   | 10              | 1GB

max_wal_senders is how many replication connections the primary allows. Each standby (and each base backup in progress) uses one.

wal_keep_size keeps at least this much WAL around after checkpoints, so a standby that falls a bit behind can catch up without going to the archive. It's a fixed buffer, not a guarantee. If the standby falls further behind than that, it'll need archived WAL or a replication slot.

Replication Slots

For guaranteed WAL retention, use replication slots:

-- Create a physical replication slot
SELECT pg_create_physical_replication_slot('standby_01');

-- Check slot status and retention
SELECT slot_name, active, restart_lsn,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;

A replication slot stops Postgres from removing any WAL its consumer hasn't received yet, so the standby can always catch up. The flip side: an inactive slot means WAL piles up without limit until the disk fills. Watch retained_wal, drop slots for decommissioned standbys, and consider setting max_slot_wal_keep_size so a forgotten slot gets invalidated instead of taking down the primary.

Logical Replication

With wal_level = logical, Postgres can decode WAL into logical row-level changes (INSERT, UPDATE, DELETE). That lets you replicate specific tables instead of the whole cluster, replicate between different Postgres major versions, and feed changes to external systems for CDC.

Logical replication has its own guide. The point here is that it runs on the same WAL. Every way Postgres propagates changes starts from that one log.

For streaming and logical replication setup in more depth, see the Replication Guide.

Conclusion

Durability, crash recovery, point-in-time restore, streaming replication, and logical decoding all come from the same write-ahead log. Once you understand segments, LSNs, checkpoints, and archiving, backup strategies, replication lag, and recovery objectives get a lot easier to reason about. And when something breaks at 3am, you'll know where to look.

Related Resources

Official docs on WAL settings and tuning.


WAL file naming, LSNs, and segment management.


Timeline IDs and history files explained.


How WAL drives both streaming and logical replication.

Updated Oct 27, 2026

This content is provided as is, and is not maintained on an ongoing basis. It may be out of date with current Snowflake instances