11. Backup, restore and upgrades
Sections
Reading settings
No voice for this language in this browser.
More voices
Code and English terms
Off: the page’s voice reads everything, with no pause between two voices.
No voice for this language in this browser.
More voices
More voices: Microsoft Edge has natural online voices, and Windows (Settings › Time & language › Speech), macOS and Android install others.
The lesson’s scripts are ops/11-backup.sh and ops/11-upgrade.sh, and the exercises are in ops/11-exercises.sh. As in lesson 10, they start small clusters inside the course container, and check.sh ops compares their output with the files of the same names in expected:
bash server.sh sh < ops/11-backup.sh| SQL Server | PostgreSQL |
|---|---|
BACKUP DATABASE, a .bak file |
pg_dump, a logical dump of one database; pg_basebackup, a physical copy of the cluster |
logins in master |
roles, dumped by pg_dumpall --globals-only |
| differential backup | incremental backup (pg_basebackup --incremental), combined with pg_combinebackup |
| log backups | WAL archiving (archive_command) |
RESTORE … WITH STOPAT, STOPATMARK |
recovery_target_time, recovery_target_name, recovery_target_xid |
RESTORE VERIFYONLY |
pg_verifybackup |
| in-place upgrade by Setup | a new cluster, and pg_upgrade from the old one |
Logical dumps
Section titled “Logical dumps”A cluster with the course schema, whose finished WAL segments are archived; the settings are explained further down (lines 21-33):
say "node1: every finished WAL segment is copied to /tmp/archive, and the WAL summarizer runs for incremental backups"initdb -D node1 --auth=trust > /dev/nullmkdir archivecat >> node1/postgresql.conf <<'EOF'shared_buffers = 32MBarchive_mode = onarchive_command = 'test ! -f /tmp/archive/%f && cp %p /tmp/archive/%f'summarize_wal = onEOFpg_ctl -D node1 -l node1.log -o "-p 5433" -w start > /dev/null || exit 1psql -X -q -p 5433 -c 'CREATE DATABASE learn' -c 'CREATE ROLE reader NOLOGIN'psql -X -q -p 5433 -d learn -c '\o /dev/null' -c '\i /course/sql/schema.sql' -c 'GRANT USAGE ON SCHEMA ci TO reader'sql 5433 -c "$counts"-- node1: every finished WAL segment is copied to /tmp/archive, and the WAL summarizer runs for incremental backups runs | jobs | steps------+------+------- 125 | 315 | 2204(1 row)pg_dump writes one database as the SQL that recreates it, from a single snapshot, while other sessions keep working. Its custom format, --format=custom, is compressed and lets pg_restore choose what to restore and run in parallel (lines 35-43):
say "A logical dump in the custom format, its table of contents, and a restore with two parallel jobs"pg_dump -p 5433 -d learn --format=custom --file=learn.dump# Each line of the list starts with a dump ID and two object IDs, removed herepg_restore --list learn.dump | grep ' TABLE DATA ' | sed -E 's/^[0-9]+; [0-9]+ [0-9]+ //'psql -X -q -p 5433 -c 'CREATE DATABASE learn_copy'pg_restore -p 5433 -d learn_copy --jobs=2 learn.dumppsql -X -p 5433 -d learn_copy -c "$counts"# Roles belong to the cluster, not to a database: pg_dump leaves them out, pg_dumpall --globals-only writes thempg_dumpall -p 5433 --globals-only | grep -E '^(CREATE|ALTER) ROLE reader'-- A logical dump in the custom format, its table of contents, and a restore with two parallel jobsTABLE DATA ci jobs postgresTABLE DATA ci runs postgresTABLE DATA ci steps postgresTABLE DATA ga iconic_chords postgresTABLE DATA ga package_refs postgresTABLE DATA ga project_refs postgresTABLE DATA ga projects postgres runs | jobs | steps------+------+------- 125 | 315 | 2204(1 row)
CREATE ROLE reader;ALTER ROLE reader WITH NOSUPERUSER INHERIT NOCREATEROLE NOCREATEDB NOLOGIN NOREPLICATION NOBYPASSRLS;pg_restore --listprints the dump’s table of contents, one line per object. Saved to a file and edited, it can be passed back with--use-listto restore only some objects, in a chosen order.--jobs=2restores with two connections: the data of several tables, then their indexes, at the same time. It needs the custom or the directory format.- A dump doesn’t contain roles, which belong to the cluster: the
GRANTtoreaderis in it, the role isn’t.pg_dumpall --globals-onlywrites the roles and tablespaces.
A dump is a copy at one moment. What happened after it is lost, and restoring a large database means reloading all its data and rebuilding every index.
Base backups and incremental backups
Section titled “Base backups and incremental backups”A physical backup copies the cluster’s files, like a standby in lesson 10 without the replication. pg_basebackup writes a backup_manifest next to them, with a checksum per file. Since PostgreSQL 17, a later backup can copy only what changed since that manifest (lines 45-54):
say "A full base backup, with its manifest, then new work on node1"pg_basebackup -p 5433 -D base -c fastls base/backup_manifestpsql -X -q -p 5433 -d learn -c "UPDATE ci.runs SET conclusion = 'failure' WHERE run_id = (SELECT min(run_id) FROM ci.runs)"
say "An incremental backup holds only the blocks changed since the manifest it is given"pg_basebackup -p 5433 -D incremental -c fast --incremental=base/backup_manifestdu -sk --exclude=pg_wal base incremental | awk '{ size[$2] = $1 } END { print "without WAL, the incremental backup is", (size["incremental"] * 3 < size["base"] ? "less" : "more"), "than a third of the full one" }'pg_combinebackup base incremental -o combinedpg_verifybackup combined-- A full base backup, with its manifest, then new work on node1base/backup_manifest
-- An incremental backup holds only the blocks changed since the manifest it is givenwithout WAL, the incremental backup is less than a third of the full onebackup successfully verified--incrementalneedssummarize_wal = onon the server: the WAL summarizer records which blocks each stretch of WAL changed.- Without their WAL, the incremental backup is less than a third of the full one: only one
UPDATEran between the two. - An incremental backup can’t be started directly.
pg_combinebackup“is used to reconstruct a synthetic full backup from an incremental backup and the earlier backups upon which it depends”. pg_verifybackupchecks the result against its manifest, and that the WAL it needs can be parsed.
The documentation adds a warning: “PostgreSQL has no built-in mechanism to figure out which backups are still needed as a basis for restoring later incremental backups” (continuous archiving). Deleting a full backup breaks every incremental one built on it.
WAL archiving and point-in-time recovery
Section titled “WAL archiving and point-in-time recovery”A base backup plus every WAL segment written since is enough to replay the cluster up to any moment: point-in-time recovery. On node1, archive_mode = on and archive_command copy each finished segment to /tmp/archive. %p is the segment’s path and %f its name; test ! -f refuses to overwrite an archived file, the documentation’s own example. In production, the archive lives on another machine.
A named restore point, then a mistake (lines 56-61):
say "Later: a named restore point, then a mistake"sql 5433 -c "SELECT pg_create_restore_point('before_truncate') IS NOT NULL AS restore_point"sql 5433 -c "TRUNCATE ci.runs CASCADE"wal=$(psql -X -Atq -p 5433 -c 'SELECT pg_walfile_name(pg_switch_wal())')until_true 5433 postgres "SELECT last_archived_wal >= '$wal' FROM pg_stat_archiver"sql 5433 -c "$counts"-- Later: a named restore point, then a mistake restore_point--------------- t(1 row)
NOTICE: truncate cascades to table "jobs"NOTICE: truncate cascades to table "steps"TRUNCATE TABLE runs | jobs | steps------+------+------- 0 | 0 | 0(1 row)pg_create_restore_pointwrites a name into the WAL, like a marked transaction for SQL Server’sSTOPATMARK.TRUNCATE … CASCADEempties the three CI tables.pg_switch_wal()ends the current segment, so that it’s archived now instead of when it fills up;pg_stat_archivertells when.
The recovery, on node2, from the combined backup (lines 63-78):
say "Point-in-time recovery on node2: the combined backup, the archived WAL, and a target"cp -r combined node2chmod 700 node2cat >> node2/postgresql.conf <<'EOF'archive_mode = offrestore_command = 'cp /tmp/archive/%f %p'recovery_target_name = 'before_truncate'recovery_target_action = 'promote'EOFtouch node2/recovery.signalpg_ctl -D node2 -l node2.log -o "-p 5434" -w start > /dev/null || exit 1until_true 5434 postgres "SELECT NOT pg_is_in_recovery()"grep -o 'recovery stopping at restore point "[a-z_]*"' node2.logsql 5434 -c "$counts"sql 5434 -c "SELECT conclusion FROM ci.runs ORDER BY run_id LIMIT 1"sql 5434 -c "SELECT substr(pg_walfile_name(pg_current_wal_lsn()), 1, 8) AS timeline"-- Point-in-time recovery on node2: the combined backup, the archived WAL, and a targetrecovery stopping at restore point "before_truncate" runs | jobs | steps------+------+------- 125 | 315 | 2204(1 row)
conclusion------------ failure(1 row)
timeline---------- 00000002(1 row)recovery.signalstarts the server in recovery.restore_commandfetches archived segments, the reverse ofarchive_command.recovery_target_namestops the replay at the restore point;recovery_target_time,recovery_target_xidandrecovery_target_lsnare the other targets.recovery_target_action = 'promote'opens the server for writes when it gets there.- The three tables are back, with the
UPDATEmade between the two backups:failure. The recovered server continues on timeline 2, as after a promotion in lesson 10. archive_mode = offonnode2: the copy mustn’t write its segments intonode1’s archive.
A major upgrade with pg_upgrade
Section titled “A major upgrade with pg_upgrade”SQL Server’s Setup upgrades an instance in place. PostgreSQL’s major versions change the format of the system catalogs, so an upgrade creates a new cluster with the new version’s initdb, and pg_upgrade moves the databases into it: it dumps and restores the schema, and reuses the data files, whose format major versions rarely change. Minor releases, 18.5 to 18.6, only need the new binaries.
ops/11-upgrade.sh needs PostgreSQL 17’s binaries next to 18’s. The image already uses the PostgreSQL project’s apt repository, so check.sh installs them as root before running the script:
# Needs the PostgreSQL 17 binaries in the course container, installed first as root (check.sh does it):# docker exec pg sh -c 'apt-get update -qq && apt-get install -qq -y --no-install-recommends postgresql-17'A PostgreSQL 17 cluster with the runs, their statistics, and extended statistics from lesson 5 (lines 14-36):
say "A PostgreSQL 17 cluster, with a table, its statistics, and extended statistics"$old/initdb -D pg17 --auth=trust > /dev/null$old/pg_ctl -D pg17 -l pg17.log -o "-p 5436" -w start > /dev/null || exit 1psql -X -q -p 5436 -c 'CREATE DATABASE learn'sql 5436 -q <<'EOF'CREATE SCHEMA ci;CREATE TABLE ci.runs ( run_id bigint PRIMARY KEY, workflow_name text NOT NULL, event text NOT NULL, conclusion text, head_branch text NOT NULL, created_at timestamptz NOT NULL);INSERT INTO ci.runsSELECT r."databaseId", r."workflowName", r.event, r.conclusion, r."headBranch", r."createdAt"FROM jsonb_to_recordset(pg_read_file('/course/data/runs.json')::jsonb) AS r( "databaseId" bigint, "workflowName" text, event text, conclusion text, "headBranch" text, "createdAt" timestamptz);CREATE STATISTICS ci.runs_workflow_event (dependencies) ON workflow_name, event FROM ci.runs;ANALYZE ci.runs;EOF# The major version only: the minor one moves with the Debian packagessql 5436 -c "SELECT current_setting('server_version_num')::int / 10000 AS version, current_setting('data_checksums') AS checksums"statistics="SELECT (SELECT count(*) FROM pg_stats WHERE schemaname = 'ci') AS column_stats, (SELECT count(*) FROM pg_stats_ext WHERE statistics_schemaname = 'ci') AS extended_stats"sql 5436 -c "$statistics"$old/pg_ctl -D pg17 -m fast -w stop > /dev/null-- A PostgreSQL 17 cluster, with a table, its statistics, and extended statistics version | checksums---------+----------- 17 | off(1 row)
column_stats | extended_stats--------------+---------------- 6 | 1(1 row)The table is simpler than schema.sql’s, which uses a virtual generated column, new in 18. The script prints the major version only: the minor one follows the Debian packages.
A first attempt, with --check, which only runs the checks (lines 38-42):
say "A new 18 cluster: initdb 18 turns data checksums on, 17 left them off"$new/initdb -D pg18 --auth=trust > /dev/null$new/pg_upgrade --old-bindir=$old --new-bindir=$new --old-datadir=pg17 --new-datadir=pg18 --check 2>&1 | grep -v '^$'rm -rf pg18$new/initdb -D pg18 --auth=trust --no-data-checksums > /dev/null-- A new 18 cluster: initdb 18 turns data checksums on, 17 left them offPerforming Consistency Checks-----------------------------Checking cluster versions okold cluster does not use data checksums but the new one doesFailure, exitinginitdb 18 enables data checksums by default, and 17 didn’t. The release notes point to the way out: “Checksums can be disabled with the new initdb option --no-data-checksums. pg_upgrade requires matching cluster checksum settings”. The new cluster is created again without them.
The upgrade (lines 44-51):
say "The upgrade: --swap moves the old data directory into the new cluster, so the 17 cluster can't be started again"$new/pg_upgrade --old-bindir=$old --new-bindir=$new --old-datadir=pg17 --new-datadir=pg18 --swap 2>&1 | grep -v '^$'$new/pg_ctl -D pg18 -l pg18.log -o "-p 5436" -w start > /dev/null || exit 1sql 5436 -c "SELECT current_setting('server_version_num')::int / 10000 AS version, count(*) AS runs FROM ci.runs"# Column statistics came across; extended statistics didn'tsql 5436 -c "$statistics"$new/vacuumdb -p 5436 --all --analyze-only --missing-stats-only 2>&1sql 5436 -c "$statistics"Performing Consistency Checks-----------------------------Checking cluster versions okChecking database connection settings okChecking database user is the install user okChecking for prepared transactions okChecking for contrib/isn with bigint-passing mismatch okChecking for valid logical replication slots okChecking for subscription state okChecking data type usage okChecking for objects affected by Unicode update okChecking for not-null constraint inconsistencies okCreating dump of global objects okCreating dump of database schemas okChecking for presence of required libraries okChecking database user is the install user okChecking for prepared transactions okChecking for new cluster tablespace directories okIf pg_upgrade fails after this point, you must re-initdb thenew cluster before continuing.Performing Upgrade------------------Setting locale and encoding for new cluster okAnalyzing all rows in the new cluster okFreezing all rows in the new cluster okDeleting files from new pg_xact okCopying old pg_xact to new server okSetting oldest XID for new cluster okSetting next transaction ID and epoch for new cluster okDeleting files from new pg_multixact/offsets okCopying old pg_multixact/offsets to new server okDeleting files from new pg_multixact/members okCopying old pg_multixact/members to new server okSetting next multixact ID and offset for new cluster okResetting WAL archives okSetting frozenxid and minmxid counters in new cluster okRestoring global objects in the new cluster okRestoring database schemas in the new cluster okAdding ".old" suffix to old "global/pg_control" okBecause "swap" mode was used, the old cluster can no longer besafely started.Swapping data directories okSetting next OID for new cluster okSync data directory to disk okCreating script to delete old cluster okChecking for extension updates okUpgrade Complete----------------Some statistics are not transferred by pg_upgrade.Once you start the new server, consider running these two commands: /usr/lib/postgresql/18/bin/vacuumdb --all --analyze-in-stages --missing-stats-only /usr/lib/postgresql/18/bin/vacuumdb --all --analyze-onlyRunning this script will delete the old cluster's data files: ./delete_old_cluster.sh version | runs---------+------ 18 | 125(1 row)
column_stats | extended_stats--------------+---------------- 6 | 0(1 row)
vacuumdb: vacuuming database "learn"vacuumdb: vacuuming database "postgres"vacuumdb: vacuuming database "template1" column_stats | extended_stats--------------+---------------- 6 | 1(1 row)pg_upgradechecks, dumps the old schema, restores it into the new cluster, then brings over the transaction IDs and the data files.--swap, new in PostgreSQL 18, moves the old cluster’s data directories into the new one instead of copying or linking files; the documentation says it “can outperform--link,--clone,--copy, and--copy-file-range, especially on clusters with many relations”. The price is in the output: “the old cluster can no longer be safely started”. Without a backup, there is no way back.- Also new in 18,
pg_upgrade“will transfer most optimizer statistics from the old cluster to the new cluster”: the six column statistics are there, and the server can plan well from its first query. The extended statistics aren’t. vacuumdb --analyze-only --missing-stats-only, another PostgreSQL 18 option, analyzes only what has no statistics, and brings the extended statistics back.pg_upgrade’s own advice is to run it with--analyze-in-stagesfirst.
On Aurora
Section titled “On Aurora”To verify: nothing in this section ran on AWS. Aurora manages backups itself, and its restores create new clusters.
- Continuous backups. “Aurora automated backups are continuous and incremental, so you can quickly restore to any point within the backup retention period”, which is “from 1–35 days”, one day by default; “you can’t disable automated backups on Aurora” (backups). There is no
archive_commandto set: “Amazon Aurora uploads log records for DB clusters to Amazon S3 continuously” (point-in-time recovery). - Point-in-time recovery creates a new DB cluster, as
node2was a new cluster here. The latest restorable time “is typically within 5 minutes of the current time”. Manual snapshots keep data beyond the retention period: “Aurora DB cluster snapshots don’t expire”. - No rewind for PostgreSQL. Backtrack, which rewinds a cluster in place, is for Aurora MySQL only (Backtrack).
- Clones. “Aurora uses a copy-on-write protocol to create a clone”, which shares storage with its source until either changes it; “you can create up to 15 clones with copy-on-write protocol”, in the same Region (cloning). A clone is the cheap way to test a restore or an upgrade on real data.
- Dumps and exports.
pg_dumpandpg_restorework from a client machine; I found the AWS procedure only in the RDS for PostgreSQL guide, not in Aurora’s. Theaws_s3extension exports a query’s result straight to S3 withaws_s3.query_export_to_s3. - Major upgrades use pg_upgrade. “To safely upgrade the DB instances that make up your cluster, Aurora PostgreSQL uses the pg_upgrade utility” (major version upgrades). Before it: “Drop logical replication slots. The upgrade process can’t proceed if the Aurora PostgreSQL DB cluster is using any logical replication slots”. Aurora takes a snapshot named with a
preupgradeprefix. - Statistics: a contradiction to check. The same page says “Optimizer statistics aren’t transferred during a major version upgrade, so you need to regenerate all statistics”, while
pg_upgrade18 transfers them, as this lesson showed. Whether an upgrade to Aurora PostgreSQL 18 keeps them isn’t documented: runANALYZEanyway. - Blue/green deployments copy a cluster to a green environment kept in sync with logical replication, where the upgrade runs, then switch over; “the switchover typically takes under a minute with no data loss” (overview). The limits are those of logical replication: “Data definition language (DDL) statements, such as CREATE TABLE and CREATE SCHEMA, aren’t replicated from the blue environment to the green environment” (considerations). The version table stops at Aurora PostgreSQL 17.
- Minor versions can be applied with zero-downtime patching (ZDP), where “application sessions are maintained except for those with dropped connections”, with a throughput drop that “typically lasts only for a few seconds or at most, approximately one minute” (minor upgrades).
Key takeaways
Section titled “Key takeaways”pg_dumpcopies one database at one moment; roles come frompg_dumpall --globals-only.- The custom format lets
pg_restorepick objects and restore in parallel. - Base backups have manifests; incremental backups need
summarize_wal, the backups they depend on, andpg_combinebackup. - WAL archiving plus a base backup gives point-in-time recovery, to a time, a named point, a transaction or an LSN.
- A major upgrade is a new cluster: checksums must match,
--swapis fast and one-way, and 18 keeps most statistics. - On Aurora, backups are continuous, restores and clones create new clusters, and upgrades still run
pg_upgrade.
Exercises
Section titled “Exercises”The solutions are in ops/11-exercises.sh, on a cluster like node1 (lines 19-28).
- Someone deletes every row of
ga.iconic_chords. Bring back that table’s rows from a custom-format dump, without touching the other tables.
Solution
say "Exercise 1: the iconic chords are deleted by mistake; only that table's data comes back from the dump"pg_dump -p 5433 -d learn --format=custom --file=learn.dumpsql 5433 -c "DELETE FROM ga.iconic_chords"pg_restore -p 5433 -d learn --data-only --table=iconic_chords learn.dumpsql 5433 -c "SELECT count(*) AS chords FROM ga.iconic_chords"-- Exercise 1: the iconic chords are deleted by mistake; only that table's data comes back from the dumpDELETE 17 chords-------- 17(1 row)--table selects the table and --data-only restores its rows into the existing table, without trying to create it again. The rows are those of the dump: whatever changed in that table after the dump is lost, which point-in-time recovery avoids.
- After a base backup, an
UPDATEmarks the last runcancelled, then a transaction deletes every step. Recover a copy that has theUPDATEbut not theDELETE, using the transaction’s ID as the target.
Solution
say "Exercise 2: a recovery that stops before the transaction of a DELETE, found by its ID"pg_basebackup -p 5433 -D base -c fastsql 5433 -q -c "UPDATE ci.runs SET conclusion = 'cancelled' WHERE run_id = (SELECT max(run_id) FROM ci.runs)"xid=$(psql -X -Atq -p 5433 -d learn -c "BEGIN" -c "DELETE FROM ci.steps" -c "SELECT pg_current_xact_id()" -c "COMMIT" | sed -n 1p)wal=$(psql -X -Atq -p 5433 -c 'SELECT pg_walfile_name(pg_switch_wal())')until_true 5433 postgres "SELECT last_archived_wal >= '$wal' FROM pg_stat_archiver"cp -r base node2chmod 700 node2cat >> node2/postgresql.conf <<EOFarchive_mode = offrestore_command = 'cp /tmp/archive/%f %p'recovery_target_xid = '$xid'recovery_target_inclusive = offrecovery_target_action = 'promote'EOFtouch node2/recovery.signalpg_ctl -D node2 -l node2.log -o "-p 5434" -w start > /dev/null || exit 1until_true 5434 postgres "SELECT NOT pg_is_in_recovery()"grep -o 'recovery stopping before commit of transaction' node2.logsql 5434 -c "SELECT (SELECT count(*) FROM ci.steps) AS steps, (SELECT conclusion FROM ci.runs ORDER BY run_id DESC LIMIT 1) AS last_run"-- Exercise 2: a recovery that stops before the transaction of a DELETE, found by its IDrecovery stopping before commit of transaction steps | last_run-------+----------- 2204 | cancelled(1 row)pg_current_xact_id() gives the transaction’s ID from inside it. recovery_target_xid stops at that transaction’s commit, and recovery_target_inclusive = off stops just before it: the steps are there, and the run is cancelled. In practice, the ID of a bad transaction is rarely known; a time from the application’s logs, with recovery_target_time, is the usual target.
Sources
Section titled “Sources”- PostgreSQL 18 documentation: backup and restore, SQL dump, continuous archiving and PITR,
pg_dump,pg_restore,pg_dumpall,pg_basebackup,pg_combinebackup,pg_verifybackup, WAL settings and recovery targets, backup control functions,pg_upgrade, upgrading a cluster,vacuumdb, PostgreSQL 18 release notes, PostgreSQL 17 release notes; the apt repository - SQL Server: backup overview, restoring to a point in time, choosing an upgrade method
- AWS: backups, point-in-time recovery, Backtrack, cloning, importing with pg_dump on RDS, exporting to S3, major version upgrades, minor version upgrades, blue/green overview, considerations and versions, all read on 2026-09-16