Bar chart of WAL archived per day by a 1 GB PostgreSQL 18 app database: 10 writes a minute 226 MB raw and 87.8 MB as zstd files, 100 writes a minute 1.64 GB raw and 675 MB as zstd files, 1,000 writes a minute 12.1 GB raw and 5.05 GB as zstd files

A nightly pg_dump loses up to a day of writes. Point-in-time recovery (PITR) loses at most a minute: you keep a base backup plus every WAL file PostgreSQL writes after it, and on a bad day you replay the WAL up to the second before the mistake. The question for a small app is what that costs: how much WAL one day of traffic makes, how big the archive gets, and how long a restore takes. We measured it on PostgreSQL 18 with a 1 GB app database at 10, 100 and 1,000 writes a minute, with wal_compression off, lz4 and zstd.

Short version: one day of writes made 226 MB of WAL at 10 writes a minute, 1.64 GB at 100 and 12.1 GB at 1,000, with default settings. About 96% of it is full-page images, copies of whole 8 KB pages written the first time a page changes after each checkpoint, so each write is expensive: 15.7 KB at 10 a minute, 8.4 KB at 1,000. Compressing the archived files with zstd cuts the day to 87.8 MB, 675 MB and 5.05 GB. A compressed base backup of the 1 GB database is 304 MB, and replaying a whole day of WAL took 1.3 s, 5.3 s and 39.9 s. The trap is archive_timeout: at 60 seconds a quiet app archives 1,440 files of 16 MiB a day, 24.2 GB, unless the archive command compresses them.

What point-in-time recovery needs

PostgreSQL writes every change to the write-ahead log (WAL) before it changes the data files, in 16 MiB segment files under pg_wal/. PITR keeps two things: a base backup, a copy of the whole data directory taken with pg_basebackup while the server runs, and the WAL archive, every segment since that backup, copied out by archive_command when a segment is full (or streamed with pg_receivewal). To restore, you unpack the base backup, tell PostgreSQL where the archive is (restore_command) and when to stop (recovery_target_time), and it replays the WAL up to that moment.

A dump is different: pg_dump writes the rows out, and a restore loads them and builds the indexes again. It is one consistent moment per run, and its size depends only on the data. WAL size depends on how many writes you do, and on a setting most people never look at: how often a checkpoint happens.

How we measured

The database is the same synthetic app we used for pg_dump formats and compression: users, orders and events with six indexes, 1,074 MB on disk (384,000 users, 1.15 million orders, 3.84 million events). A "write" is one transaction from a mix that looks like a small app: 60% insert an event (a jsonb row), 30% update one random order's status, 10% insert an order, run with pgbench and three custom scripts.

Waiting a real day per setting would take weeks, so we simulated the day. The WAL a day makes depends on how many writes land between checkpoints, because the first change to each page after a checkpoint writes the whole page. PostgreSQL checkpoints every 5 minutes by default (checkpoint_timeout), 288 times a day. So one simulated day is 288 rounds of 5 minutes' worth of writes (50, 500 or 5,000) followed by CHECKPOINT. archive_timeout was simulated the same way, with pg_switch_wal() after every 1 or 5 minutes' worth of writes.

text
machine     Intel Core i5-12500H laptop, Docker Desktop 29.6 on WSL2, 6 CPUs, 11.7 GiB, NVMe disk
server      postgres:18 image (PostgreSQL 18.6), defaults except the settings under test;
            data checksums on (the PostgreSQL 18 default), full_page_writes on
archive     archive_command = 'test ! -f /arch/%f && cp %p /arch/%f', same disk
WAL size    pg_current_wal_lsn() before and after the day; full-page share from pg_waldump --stats
compressed  each archived segment through zstd -3, lz4 -1 and gzip -6, as an archive_command would
replay      base backup taken before the day, restore_command = 'cp /arch/%f %p', timed from start
            until pg_is_in_recovery() is false; row counts checked against the source
runs        one simulated day per setting, 3 replays each (median)

One setting at 1,000 writes a minute (1.44 million transactions) took 25 minutes to simulate.

WAL per day at 10, 100 and 1,000 writes a minute

Writes a minutewal_compressionWAL per dayPer writeFull-page imagesArchive, lz4 filesArchive, gzip filesArchive, zstd files
10off (default)226 MB15.7 KB97.7%130 MB94.1 MB87.8 MB
10lz4145 MB10.1 KB96.4%120 MB95.0 MB89.3 MB
10zstd103 MB7.1 KB94.9%101 MB99.6 MB96.2 MB
100off (default)1.64 GB11.4 KB97.1%931 MB697 MB675 MB
100lz41.06 GB7.3 KB95.5%895 MB718 MB702 MB
100zstd768 MB5.3 KB93.7%750 MB740 MB728 MB
1,000off (default)12.1 GB8.4 KB95.8%6.75 GB5.18 GB5.05 GB
1,000lz47.61 GB5.3 KB93.3%6.66 GB5.37 GB5.33 GB
1,000zstd5.67 GB3.9 KB91.0%5.46 GB5.37 GB5.34 GB
Bar chart of WAL per day at 100 writes a minute: wal_compression off 1.64 GB, lz4 1.06 GB, zstd 768 MB; archive files compressed with zstd 675 MB to 728 MB
1 GB database, 144,000 writes in one simulated day, PostgreSQL 18.6.

What the table shows:

  • WAL is mostly full-page images. 98% of the bytes at 10 writes a minute and 96% at 1,000. The row changes themselves were only 47 MB a day at 100 writes a minute, about 329 bytes per write. Each write touches a table page and two to four index pages, and in a 1 GB database most of them have not changed since the last checkpoint, so each costs a full 8 KB copy.
  • Fewer writes cost more per write. 15.7 KB at 10 a minute against 8.4 KB at 1,000: with more writes between checkpoints, more of them land on a page that already has its full-page image in this round.
  • wal_compression shrinks the WAL, not the archive. zstd made 53% less WAL and lz4 36% less at 100 writes a minute. But compressing the archived files with zstd gets as far or further from uncompressed WAL (675 MB against 728 MB), because one compressor over a whole 16 MiB file beats compressing each page alone. `wal_compression` still pays where WAL travels uncompressed: fewer bytes written on the server, a smaller `pg_wal/`, less replication traffic, and `pg_receivewal`, which can only compress with gzip or lz4.
  • zstd is the archive compressor to use. gzip came within a few percent of it on size but costs about three times the CPU (see the pg_dump article); lz4 files were 30 to 50% bigger.

The archive_timeout trap

archive_command runs only when a 16 MiB segment is full. At 10 writes a minute that takes about 107 minutes, so without a timeout the newest stretch of writes exists only on the database server. archive_timeout forces a new segment after that many seconds, and the PostgreSQL documentation warns that a segment closed early is "still the same length as completely full files":

Writes a minutearchive_timeoutFiles archived per dayArchive, plain copiesArchive, lz4 filesArchive, zstd files
100 (off)6103 MB101 MB96.2 MB
10300 s2884.83 GB119 MB98.2 MB
1060 s1,44024.2 GB195 MB100 MB
1000 (off)45768 MB750 MB728 MB
100300 s2884.83 GB765 MB728 MB
10060 s1,44024.2 GB843 MB735 MB

With cp as the archive command, a 60-second timeout turns 103 MB of WAL into 24.2 GB a day, 235 times more. The rest of a segment after a forced switch compresses to almost nothing, so with zstd in the archive command the same day is 100 MB instead of 96.2 MB. Compress in the archive command and a one-minute timeout is nearly free; copy plain files and it fills a disk in weeks. (These runs used wal_compression = zstd; the zstd-file sizes barely depend on it.) At 1,000 writes a minute a segment fills every 119 seconds on its own, so the timeout doesn't matter there.

pg_receivewal avoids the question: it streams WAL over a replication connection as it is written, so it needs no timeout and loses at most the last few seconds (none with --synchronous and a synchronous standby setting). It needs a second machine or process that stays connected, and it can compress only with gzip or lz4.

Base backup size and time

Formatpg_basebackup options (plus -X stream -c fast)SizeTime
plain directory-Fp1.11 GB1.5 s
tar-Ft1.11 GB1.4 s
tar, gzip on the client-Ft -z299 MB30.0 s
tar, lz4 on the server-Ft --compress=server-lz4437 MB2.4 s
tar, zstd on the server-Ft --compress=server-zstd304 MB5.1 s
tar, zstd on the client-Ft --compress=client-zstd304 MB5.9 s

A base backup copies the data files as they are, indexes included, so it is bigger than a dump: 1.11 GB uncompressed for a 1,074 MB database, 304 MB with zstd, against 185 MB for pg_dump -Fc -Z zstd of the same database. zstd took 5.1 s; gzip, which -z still picks, took 30.0 s for about the same size. server-zstd compresses on the database server and sends less over the network; client-zstd does it on the machine running pg_basebackup. Both made the same file.

Restore time: replaying one day

Writes a minutewal_compressionWAL replayedReplay time (median of 3)WAL per second
10off (default)226 MB1.3 s174 MB/s
10lz4145 MB1.0 s149 MB/s
10zstd103 MB1.4 s74 MB/s
100off (default)1.64 GB5.3 s310 MB/s
100lz41.06 GB4.3 s245 MB/s
100zstd768 MB7.2 s107 MB/s
1,000off (default)12.1 GB39.9 s304 MB/s
1,000lz47.61 GB40.4 s189 MB/s
1,000zstd5.67 GB53.5 s106 MB/s
Bar chart of time to replay one day of WAL: 10 writes a minute with wal_compression off 1.3 s, 10 writes a minute with wal_compression lz4 1.0 s, 10 writes a minute with wal_compression zstd 1.4 s, 100 writes a minute with wal_compression off 5.3 s, 100 writes a minute with wal_compression lz4 4.3 s, 100 writes a minute with wal_compression zstd 7.2 s, 1,000 writes a minute with wal_compression off 39.9 s, 1,000 writes a minute with wal_compression lz4 40.4 s, 1,000 writes a minute with wal_compression zstd 53.5 s
Base backup taken before the day, WAL read from local disk, median of 3 replays, PostgreSQL 18.6.

Replay is fast: one day at 1,000 writes a minute, 12.1 GB of WAL, replayed in 39.9 s. Every replay ended with the same row counts as the source. At 1,000 writes a minute off replayed fastest and zstd slowest; at 100 writes a minute lz4 took 4.3 s, off 5.3 s and zstd 7.2 s. lz4 has fewer bytes to read and decompresses almost for free; zstd page images cost more to decompress than their smaller size saves.

Replay time grows with the days since the base backup. With a weekly base backup, a restore on day 7 replays seven days: about 37.2 s at 100 writes a minute and 4.7 minutes at 1,000, plus unpacking the base backup and decompressing each WAL file in restore_command. For comparison, pg_restore of the same 1 GB database from a dump took 14.6 s, most of it rebuilding indexes, which a PITR restore never does.

PITR or a nightly dump: storage and cost

Writes a minuteWAL per day (zstd files)7-day window, weekly base backupR2 a month7-day window, daily base backupR2 a month
1087.8 MB1.84 GB$0.0283.14 GB$0.047
100675 MB10.1 GB$0.1517.84 GB$0.118
1,0005.05 GB71.3 GB$1.06942.8 GB$0.642
nightly pg_dump, any rate–1.48 GB (8 dumps)$0.022––

For a 7-day recovery window with a weekly base backup you keep two base backups and up to 14 days of WAL (the older backup is needed until the newer one is a week old); with daily base backups, 8 backups and 8 days of WAL. Base backups are 304 MB each with zstd. Prices at Cloudflare R2's $0.015 per GB-month, as listed on 28 October 2026 (the first 10 GB a month are free). At 10 and 100 writes a minute PITR costs cents a month. At 1,000 writes a minute the weekly scheme keeps 71.3 GB, about $1.07 a month, and daily base backups cut that to 42.8 GB and the worst-case replay to one day. The bigger costs there are 5.05 GB of upload a day and the restore time.

When a daily dump is enough

  • A day of lost writes is survivable. Content that can be re-entered, a prototype, an internal tool, data that can be rebuilt from another system. A nightly `pg_dump -Fc -Z zstd` kept off the machine is simple, and for 1 GB it takes 6 s to make and 15 s to restore.
  • You want back one database or one table. A dump restores per database, and `pg_restore -t` picks a single table. PITR always restores the whole cluster, every database in it.
  • You can't change server settings. On a managed or shared PostgreSQL you usually can't set `archive_command` or open a replication connection, so a dump from your connection string is what you have.

When you need point-in-time recovery

  • Writes are money or promises. Orders, payments, bookings, sign-ups: losing the afternoon means refunds and apologies.
  • The likely disaster is a mistake, not a dead disk. A `DELETE` without `WHERE` at 14:05 or a bad migration: PITR stops at 14:04:59; a dump takes you back to last night.
  • The cost is small anyway. At 10 to 100 writes a minute the archive is 87.8 MB to 675 MB a day compressed.

Many teams do both: PITR for the last week or two, and a weekly or monthly dump kept for a year, because a dump restores into any newer PostgreSQL version and a base backup only into the same major version.

Setting it up

On your own PostgreSQL 18 server. 1. Archive every segment, compressed, and close a segment at least every minute (postgresql.conf; archive_mode needs a restart):

ini
wal_level = replica              # the default
archive_mode = on
archive_command = 'test ! -f /backup/wal/%f.zst && zstd -q -o /backup/wal/%f.zst %p'
archive_timeout = 60             # cheap only because the files are compressed
wal_compression = lz4            # optional: less WAL written and replayed

2. Take a base backup every week (or every night at 1,000 writes a minute) from cron, and copy /backup to another machine or object storage:

bash
pg_basebackup -D /backup/base/$(date +%F) -Ft --compress=server-zstd -X stream -c fast
# drop WAL older than the oldest base backup you keep (its first segment name is in its backup_label):
pg_archivecleanup -x .zst /backup/wal 000000010000000A000000F3

3. Watch it: select archived_count, failed_count, last_failed_wal from pg_stat_archiver;. A failing archive command makes pg_wal/ grow until the disk is full, so alert on failed_count.

4. To restore to 14:04:59: stop the server, move the old data directory aside, unpack the base backup (base.tar.zst into the empty data directory, the pg_wal.tar file into its pg_wal/), then add the recovery settings and start:

bash
cat >> $PGDATA/postgresql.conf <<'CONF'
restore_command = 'zstd -d -q -o %p /backup/wal/%f.zst'
recovery_target_time = '2026-10-31 14:04:59+05:30'
recovery_target_action = 'promote'
CONF
touch $PGDATA/recovery.signal
pg_ctl -D $PGDATA start

Without recovery_target_action = 'promote' PostgreSQL pauses at the target, read-only, so you can check the data first and then run select pg_wal_replay_resume();. Practise the restore on a scratch machine once a month. Tools such as pgBackRest, WAL-G and Barman wrap these steps with retention, parallel transfer and checks.

Backups on systemi

Every systemi app gets its own PostgreSQL 18 database and role. Server settings such as archive_command and replication connections are not open to an app's role, so for your own copies use pg_dump with the connection string from the console, as in pg_dump formats and compression, measured; each dump holds exactly one app (why one database per app is cheap). If your app takes orders all day, dump more often than nightly: a 1 GB database dumps in about 6 seconds.

Sources: our own tests on 31 October 2026 (one simulated day per setting, 3 replays each; scripts and raw output kept with the article's change request); PostgreSQL 18 documentation: WAL configuration (wal_compression methods pglz/lz4/zstd, default off; full-page images; checkpoint_timeout default 5 min; archive_timeout and early-closed segments being full length; recovery targets), continuous archiving and PITR, pg_basebackup, pg_receivewal (no archive_timeout needed; compression gzip or lz4 only); Cloudflare R2 pricing ($0.015 per GB-month, 10 GB free), as listed on 28 October 2026.

systemi gives every app its own PostgreSQL 18 database, with the connection string for pg_dump ready in the console.

Read the databases guide