Postgres in Docker: four backups taken under load
Tested on PostgreSQL (postgres:18 image) 18.6
Test result
On this test every method (pg_dump plain and custom, pg_basebackup, and a raw cp -a of the running data directory) restored to a database whose row counts, balance invariant and amcheck all passed in 3 of 3 runs. That does not make the raw copy safe: it survived by crash recovery, which PostgreSQL's documentation says you cannot rely on without a shutdown or an atomic snapshot.

All four methods produced a backup that restored into a fresh container and passed every check, three runs out of three. If you only read one row, read the last one: a cp -a of the data directory of a running Postgres also passed, and the PostgreSQL manual still says not to do it. This page shows what the test saw and why it is not permission.
What was measured
| Item | Value |
|---|---|
| pgbench scale | 50 (5,000,000 accounts, 500 tellers, 50 branches) |
| pg_database_size after init | 792 MB |
| Server | PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit |
| data_checksums | on |
| Control check on the live source | counts: 50,500,5000000,0 | sums(accounts,tellers,branches,history.delta): 0,0,0,0 | amcheck_rc: 0 ms: 2366 | |
From raw/pg-info.txt and raw/control-source.check.txt.
The database was created with pgbench -i -s 50 inside a postgres:18 container whose data directory sat on a bind mount. During each backup a pgbench -n -c 4 -j 2 TPC-B write load ran, starting 8 s before the backup began and continuing until it ended. Each of the three runs did all four methods in turn on the same, growing database, and each backup was restored into a new container on an empty directory.
| Method | Backup time | Backup size | Restore time | Started on first attempt | Came up | Row counts match | Balance invariant holds | amcheck clean |
|---|---|---|---|---|---|---|---|---|
| pg_dump plain (-Fp, psql restore) | 3.3 s (2.7-4.6) | 490 MB (488-491) | 12.0 s (10.5-15.3) | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 |
| pg_dump custom (-Fc, pg_restore) | 3.2 s (3.0-3.3) | 15 MB (14-16) | 11.5 s (10.6-13.3) | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 |
| pg_basebackup (-X stream) | 5.3 s (5.1-5.4) | 844 MB (841-854) | 2.7 s (2.7-2.8) | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 |
| cp -a of the live data directory | 3.8 s (3.3-4.2) | 1496 MB (1493-1499) | 5.5 s (5.0-5.7) | 3/3 | 3/3 | 3/3 | 3/3 | 3/3 |
Median (min-max) of 3 runs; each run starts a fresh pgbench load (-n, 4 clients, 2 threads) 8 s before the backup and keeps it running until the backup ends. Database: pgbench scale 50 (see database.json). VM 4 vCPU, 3 GB RAM. Restore time runs from the same point for all four methods: the backup artifact exists and nothing has been restored. For the dumps it covers starting an empty container, waiting for its first-start initialisation, createdb and the replay; for the directory copies it covers the copy into place and the container start, including crash or backup recovery. Balance invariant: sum(abalance) = sum(tbalance) = sum(bbalance) = sum(history.delta). amcheck: pg_amcheck --heapallindexed.
Restore checks, applied to the restored container:
- Row counts: 50 branches, 500 tellers, 5,000,000 accounts.
- The pgbench balance invariant. Every transaction adds the same delta to one account, one teller and one branch and inserts it in
pgbench_history, so in a consistent databasesum(abalance) = sum(tbalance) = sum(bbalance) = sum(history.delta). The-n(no vacuum) flag matters: without it pgbench truncates the history table at the start of each run and the invariant breaks across runs, which is what our first attempt did (see below). pg_amcheck --heapallindexedon the restored database, which uses theamcheckextension to verify heap pages and B-tree indexes. Data checksums were on (show data_checksumsreturnedon); the test did not change that setting.
Per-run output is in checks.json and raw/*.check.txt.
Reading the table
pg_dump -Fcmade a 15 MB file from a 792 MB database; plain SQL was 490 MB. pgbench data is synthetic and compresses well, so the ratio will not hold for your data.pg_basebackupwas the slowest to take (5.3 s) but the fastest to restore (2.7 s). Restore time runs from the same point for all four methods (the backup artifact exists, nothing is restored yet), so the dumps include starting an empty container and its first-start initialisation (1.7 to 3.2 s of the total) as well ascreatedband the replay. The dumps took 12.0 s (plain) and 11.5 s (custom). The directory copies took 2.7 s (pg_basebackup) and 5.5 s (raw copy, which also ran crash recovery). That the dumps are slower is consistent with them replaying SQL orpg_restoreand rebuilding the indexes whilepg_basebackupcopies files and needs no rebuild, but the test did not measure where the dump time went.- The raw copy was 1,496 MB, larger than the 844 MB basebackup. It copies the whole data directory as it stands, WAL segment files included; why the two differ by this much was not investigated further.
What the raw copy did
It started on the first attempt in 3 of 3 runs, ran crash recovery, and passed all the checks. Each restore log (raw/rawcopy-r*.restore-attempt1.log) contains “database system was not properly shut down; automatic recovery in progress”. The pg_basebackup restores instead logged “starting backup recovery”, the path that comes from a backup_label.
Our reading, as an inference rather than a measurement: if cp -a copied global/pg_control before it reached pg_wal, the WAL in the copy would extend past the point where the data files were copied, and replay from the last checkpoint would repair any torn pages. The test did not record the order in which cp -a copied files, and that order follows the directory listing, so it is luck of the tool and the filesystem, not a guarantee. The PostgreSQL manual says the server must be shut down to get a usable file system backup, unless you take an atomic snapshot of the data directory including the WAL. Three clean runs at about 500 transactions per second (the pgbench progress lines in raw/*.pgbench.log) on a small VM do not test a busier server, a slower disk, or a copy tool that orders files differently.
What went wrong in the test
- First attempt: the balance invariant failed for the custom-format dump and for pg_basebackup restores (the first plain-dump restore passed, because it came right after a fresh history table). The cause was the test, not Postgres: pgbench without
-ntruncatespgbench_historyon each start. The run was discarded and redone with-n. - An earlier version of the script timed the dump restores from the moment the empty container was ready, but the directory copies from the start of the copy, so the restore times were not comparable (and the raw copy ranged from 4.5 s to 11.7 s across its three runs). The script now starts one timer for all four methods, and the whole test was run again; all numbers on this page are from that second run, which passed every check again.
- First attempt: the raw copy copied the bind-mount root (the
18/dockerdirectory ended up nested inside the directory used as the data directory), so Postgres refused to start. That was a script bug, fixed by copying18/dockeritself.
Reproduce it
Show the full file
#!/usr/bin/env bash
# Test pg-backup: Postgres 18 in Docker, pgbench scale 50 under continuous write load; pg_dump (plain, custom), pg_basebackup, raw copy.
set -uo pipefail
source "$(dirname "$0")/../_common/lib.sh"
ID=pg-backup; NAME=h-$ID; D="$ROOT/lab/tests/$ID"; E="$ROOT/evidence/tests/$ID"; RAW="$E/raw"
rm -rf "$RAW"; mkdir -p "$RAW"
trap 'vm down $NAME' EXIT
host_env "$RAW"
vm up $NAME || exit 1
gssh 'sudo apt-get install -y -qq time >/dev/null 2>&1; docker pull -q postgres:18 >/dev/null && echo pulled'
vm scp-to "$D/guest.sh" /tmp/pg-guest.sh
gssh 'SCALE=50 bash /tmp/pg-guest.sh' > "$RAW/guest-stdout.txt" 2>&1
vm scp-from /srv/pgraw/. "$RAW/"
guest_env "$RAW" 'docker --version; docker exec pg1 postgres --version; docker exec pg1 pgbench --version'
python3 "$D/summarize.py" "$RAW" "$E"The guest-side script guest.sh has the exact commands. The backup commands were:
# plain dump, custom dump (run against the container, output redirected on the VM)
docker exec pg1 pg_dump -U postgres -d pgb > backup.sql
docker exec pg1 pg_dump -U postgres -Fc -d pgb > backup.dump
# base backup, streamed WAL, inside the container
docker exec pg1 pg_basebackup -U postgres -D /backups/base -X stream -c fast
# raw copy of the running data directory (the method this page does not recommend)
sudo cp -a /srv/pgdata/18/docker /srv/backups/raw
Restores: psql -v ON_ERROR_STOP=1 < backup.sql and pg_restore --exit-on-error -d pgb < backup.dump into a new container with an empty data directory and createdb pgb; for the base backup and the raw copy, the directory was copied to /var/lib/postgresql/18/docker of a new container and started.
pg_dump dumps one database and, per the PostgreSQL manual, does not dump roles or tablespaces; for a whole cluster the manual points to pg_dumpall (--globals-only for the roles alone). The test restored a single database into a container that already had the postgres superuser and did not try that, so a restore into a fresh server would also need the roles. pg_restore without --exit-on-error carries on after an error, which is why the restore command above has it.
The restore script has one retry path for the directory copies: if the container does not come up and a stale postmaster.pid is present, it deletes the file and starts once more. It never ran (start_attempts 1 in all six directory restores), so every restore on this page started on the first attempt.
What this test did not cover
- Point-in-time recovery and WAL archiving. Only the four methods above were compared.
- Larger databases. The database was 792 MB and the VM had 3 GB of RAM, so everything was largely cached and times are short.
- A heavier write load or a slower disk, where a raw copy is more likely to fail. The raw copy’s 3 passes say nothing about its failure rate.
- Other Postgres versions, replicas, extensions beyond
amcheck, and tablespaces. - Whether
amcheckwould catch every kind of corruption; it found nothing in any restore. - Network backup targets and encryption of the backups.
Sources and how this was made
- PostgreSQL 18 docs: File System Level Backup
- PostgreSQL 18 docs: SQL Dump
- PostgreSQL 18 docs: pg_basebackup
- PostgreSQL 18 docs: pg_amcheck
- PostgreSQL 18 docs: pgbench
How this page was made: the commands above were run by an AI agent on the test box on November 2, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.