PostgreSQL Backup and Restore: A Tested Runbook
The Helm7 team · Published 19 September 2026 · Checked 30 September 2026 · 7 min read

A PostgreSQL backup you have never restored is a hope, not a plan. Most teams run a nightly dump and stop there, which means a bad update at teatime costs them the whole day. This runbook covers pg_dump, base backups and WAL archiving, then the restore test that shows whether any of it works. It follows one worked incident from start to finish.
In short
- A nightly dump is one PostgreSQL backup with a day-long gap: your worst case is a day of lost writes. In the worked example, 14 hours 42 minutes.
- Point-in-time recovery needs a base backup plus archived WAL. Set archive_mode to on and make archive_command return zero only on real success.
- pg_dump skips roles and tablespaces. Run pg_dumpall with --globals-only alongside it.
- pg_verifybackup checks files, not data. Only a real restore into a scratch database proves a backup works.
- Time your restore. The duration is your outage.
A worked example: the update that ran without a WHERE
This is an invented scenario, but the arithmetic is real. A small team runs a Postgres database with one PostgreSQL backup: a pg_dump at 02:00 every night. On Friday 25 September a deploy script runs an UPDATE with no WHERE clause at 16:42. Every customer row now has the same plan name. Somebody notices at 16:50.
With only the nightly dump, the best restore is the 02:00 state. That is 16:42 minus 02:00, or 14 hours 42 minutes of orders, sign-ups and edits gone. With WAL archiving on top of a base backup, the target is 16:41, a minute before the mistake, and you lose at most a minute of work.
The rest of this post builds both, so you can see exactly what each one buys you.
What is the difference between a dump and a base backup?
People say "backup" for two very different things. A dump is a logical export of one database: SQL or an archive that recreates your tables and rows. A base backup is a physical copy of the whole cluster's files. Point-in-time recovery needs the second kind, plus a continuous stream of write-ahead log (WAL) files.
| pg_dump | pg_basebackup plus archived WAL | |
|---|---|---|
| What it copies | One database | The whole cluster |
| Restores to | The moment the dump began | Any point after the backup ended |
| Connections used | One, or jobs plus 1 with parallel dump (5 jobs = 6) | Two with the default stream method |
| Restore tool | psql or pg_restore | A server started in recovery |
| Roles and tablespaces | Not included, use pg_dumpall | Included |
| Loads into a newer major version | Expected to | Not the purpose |
Neither PostgreSQL backup replaces the other. A dump is portable and easy to inspect, which makes it good for migrations and small databases. Base backups plus WAL are what save you on Friday afternoon. For the deeper walk-through of the second kind, see Postgres point-in-time recovery.
Take and restore a PostgreSQL backup in five steps
- 1Dump in custom formatRun pg_dump with -Fc. The custom format is compressed by default and lets pg_restore pick individual tables later. For a big database use the directory format, -Fd, which is the only one that allows parallel dumps with -j.
- 2Move the file off the machineA dump on the same disk as the database survives nothing that matters. Copy it to object storage or another host.
- 3Dump roles separatelypg_dump does not include roles or tablespaces. Run pg_dumpall with --globals-only, or you will restore tables owned by users that do not exist.
- 4Restore into an empty targetUse pg_restore with --create and -d postgres to make the database, or point -d at one you made. Add --clean only when you mean to drop existing objects first.
- 5Analyse and checkRun ANALYZE. The docs say optimiser statistics are not fully carried over, so the planner starts blind. Then compare row counts on your busiest tables.
pg_dump -Fc -f /backups/app-2026-09-25.dump "$DATABASE_URL" pg_dumpall --globals-only -f /backups/globals.sql pg_restore --create -d postgres /backups/app-2026-09-25.dump
One practical point. A dump loads into a newer major version, and that makes it your upgrade path too. PostgreSQL 14 stops getting fixes on 12 November 2026, so a restore into a newer version is a job many teams have coming.
Set up a PostgreSQL backup that can replay WAL
A base backup is taken with pg_basebackup, which the docs describe as taking the backup "without affecting other clients of the database". Use tar format with gzip, ask for progress, and stream the WAL it needs so the backup stands alone.
pg_basebackup -h db.internal -U replicator -D /backups/base-2026-09-25 -Ft -z -P -X stream pg_verifybackup /backups/base-2026-09-25
pg_verifybackup is useful but modest. It checks files and checksums against the backup manifest. It does not check that the databases work or hold the right data. Keep that limit in mind when we get to testing.
Then switch on continuous archiving. The default wal_level is already replica, which is enough. You need archive_mode on and an archive_command that copies each finished WAL segment somewhere durable.
archive_mode = on archive_command = 'test ! -f /mnt/server/archivedir/%f && cp %p /mnt/server/archivedir/%f'
“It is important that the archive command return zero exit status if and only if it succeeds.”
The reason is in the next sentence of the docs. On a zero result, PostgreSQL assumes the file is archived and will remove or recycle it. A command that returns zero after a failed upload quietly throws your history away.
Restore to 16:41, not to 02:00
Back to Friday. The team stops the app, then restores the base backup into a fresh data directory. They set where to stop and where to fetch WAL from, and create an empty recovery.signal file.
restore_command = 'cp /mnt/server/archivedir/%f %p' recovery_target_time = '2026-09-25 16:41:00+00' recovery_target_action = 'promote'
- Keep a copy of the broken data directory first, if you have space. You may want the rows written after 16:41.
- Remove old files from pg_wal in the restored directory, and make sure the files belong to the postgres user, not root.
- Start the server. It replays archived WAL up to the target.
- The default action at the target is pause, so a check is possible before you commit. Setting promote, as above, lets it open for writes straight away.
- Inspect the data before letting users back in. If it is wrong, go back and pick a new target.
Two limits from the docs are worth memorising. The target must fall after the end of the base backup, so a backup cannot recover to a time while it was still running. And when recovery ends, PostgreSQL starts a new timeline, so later WAL does not overwrite the old.
How do you know a PostgreSQL backup actually restores?
You restore it. On a schedule, into a scratch database, with a check that fails loudly. A backup that exits with status zero has told you only that a file exists.
#!/usr/bin/env bash
set -euo pipefail
LATEST=$(ls -t /backups/*.dump | head -1)
createdb restore_check
pg_restore --exit-on-error -d restore_check "$LATEST"
ROWS=$(psql -Atc "SELECT count(*) FROM orders" restore_check)
dropdb restore_check
test "$ROWS" -gt 0 || { echo "restore produced an empty orders table"; exit 1; }Time the restore as well. The number you need in an incident is how long you are down, and you only learn it by doing the restore. Write it in the runbook next to the command.
There is one more gap a scratch restore can expose. The pg_dump docs say it only dumps a single database, and that global objects such as roles and tablespaces need pg_dumpall. Restore a dump onto a fresh server and every table owner may be missing, so the load fails or the app cannot log in. Dump the globals with pg_dumpall alongside your nightly dump and keep the two files together. Then your weekly check tests the roles as well as the rows.
Do the same for base backups: start a throwaway server from the latest one, replay WAL to a recent time, and query it. If you would rather not run any of this yourself, Supabase compared with Neon sets two managed options side by side.
What Helm7 does
Helm7 databases run Postgres 18. Every PostgreSQL backup on Builder is daily and kept for 7 days. Pro and Scale add point-in-time recovery. You can also take a backup on demand with the database_backup operation, which keeps it for 7 days. Our terms say we take backups and test restoring them, and that you should still keep your own.
Restores are careful by design. The database_restore operation takes a backup id or a point in time, replaces the current contents, and refuses to run without confirm set to true. It can restore into a new database alongside the old one, and that is the safer first move when you are not sure. Restores are API and MCP operations today, with no CLI command. The quickstart shows how to create a database, and pricing lists what each plan keeps.
Common questions
Is pg_dump enough for a PostgreSQL backup?
For a small database or a migration, yes. But a dump only restores to the moment it began, so your worst case is everything since the last dump. Base backups with archived WAL let you recover to any moment after the backup ended.
What is point-in-time recovery for, practically?
The clearest case is a mistaken bulk update or a bad deploy. You restore to a moment just before it ran, rather than losing every good write since your last nightly backup.
How often should I test a restore?
Often enough that a broken backup is found before an incident. Automate a restore into a scratch database after every backup if you can. A monthly manual test is better than nothing.
What is the difference between recovery.signal and standby.signal?
recovery.signal makes a restored server replay WAL to its target and then come up as a normal, writable database. standby.signal keeps it replaying WAL continuously as a read replica, so it never finishes recovering on its own.
Can I restore a pg_dump into a newer Postgres version?
Yes. The PostgreSQL docs say pg_dump output can be expected to load into newer server versions. They do not guarantee it loads into an older major version, even one the dump came from.