Upgrade a PostgreSQL 15 Cluster to 16 with pg_upgrade
pg_upgrade takes you from PostgreSQL 15 to 16 without a long dump and restore, but it is still service-disrupting with a real point of no return. Treat it that way from the start.
The route
Jump straight to the step you need, or tick off Done means at the end.
You will prepare the 15 cluster, run 16.15's pg_upgrade checks, and then perform the major-version upgrade in copy mode. Allow at least thirty minutes for preparation and checks, plus whatever time the actual data copy takes.
Take and test a backup before you start, book a maintenance window, and make sure no clients can connect during the upgrade. A major-version jump is not the same animal as a minor update like 16.14 to 16.15; pg_upgrade exists specifically for changing major version without a normal dump and restore.
1. Confirm the installed tool
Run these read-only checks as the PostgreSQL installation user, or another account that can inspect the binaries. This machine has PostgreSQL 16.15 from Ubuntu's postgresql-16 package. Always run the new version's pg_upgrade, never the old one:
$ /usr/lib/postgresql/16/bin/pg_upgrade --version
pg_upgrade (PostgreSQL) 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1)
$ dpkg-query -W -f='${Package} ${Version}\n' postgresql-16
postgresql-16 16.15-0ubuntu0.24.04.1
Checkpoint
Identify four paths before you go any further. OLD_DATA is the old cluster's data directory, NEW_DATA is a new cluster already initialised with PostgreSQL 16, and the two bin directories hold the matching old and new executables:
$ OLD_DATA=/var/lib/postgresql/15/main
$ NEW_DATA=/var/lib/postgresql/16/main
$ OLD_BIN=/usr/lib/postgresql/15/bin
$ NEW_BIN=/usr/lib/postgresql/16/bin
$ test -x "$OLD_BIN/postgres" && test -x "$NEW_BIN/postgres" && echo 'binaries found'
binaries found
Replace these with the real paths on your host. A version number in a package name is not proof the directory actually exists.
2. Prepare the new cluster and extensions
Install the PostgreSQL 16 binaries and initialise NEW_DATA with initdb, using settings compatible with the old cluster. The new server does not need to be started for any of this. If a package already created the cluster for you, verify it rather than initialising over the top.
Install the PostgreSQL 16 versions of every extension shared object the old cluster uses, custom modules included. Do not run CREATE EXTENSION again, the definitions carry over on their own, and pg_upgrade will tell you which extensions need a later update script. Copy over any custom full-text dictionaries, synonym files, thesauri and stop-word files too.
Save the new cluster's configuration, then arrange authentication so the installation user can reach both clusters during the run. Peer authentication or a properly protected ~/.pgpass works well for this. Treat that password file as sensitive: it belongs nowhere near a command line or a script under version control.
3. Stop services and choose safe upgrade mode
Stop both clusters through your normal service manager, or pg_ctl if you manage them directly. Both commands need the PostgreSQL installation user, and possibly elevated privileges if that user does not own the service:
$ pg_ctl -D "$OLD_DATA" stop
$ pg_ctl -D "$NEW_DATA" stop
Confirm both servers are actually down before continuing, and do not start either one again between this checkpoint and the upgrade itself. Any replication or log-shipping standby needs the final changes before this shutdown happens.
Copy mode is the default. It leaves the old data files untouched, costs more time and disk space, and gives you the cleanest rollback boundary going. --link is faster but needs both data directories on the same filesystem, and starting the new cluster makes the old one unusable from that point on. --clone can match that speed on supported filesystems while leaving the old cluster alone. Do not reach for either shortcut just to save a few minutes.
4. Run a no-change compatibility check
Create a fresh, writable working directory, this is where pg_upgrade writes its logs and generated SQL, then run the check from there with the new binary. This does not touch cluster data, and it can even check a running old server, so long as the old and new ports differ:
$ mkdir -p /var/tmp/pg-upgrade-16
$ cd /var/tmp/pg-upgrade-16
$ /usr/lib/postgresql/16/bin/pg_upgrade \
--check \
--old-datadir "$OLD_DATA" \
--new-datadir "$NEW_DATA" \
--old-bindir "$OLD_BIN" \
--new-bindir "$NEW_BIN" \
--jobs 4 \
--retain
Performing Consistency Checks
Clusters are compatible
Progress lines vary, but you want a successful exit and that final compatibility message. Planning to use --link or --clone for real? Include the same mode here too, so its filesystem requirements actually get tested.
Stop at the first failed check. Read the generated log and fix whatever it names, a missing extension library or an incompatible build setting, in whichever cluster is at fault. Skipping the check because the directories look fine is how upgrades go sideways.
5. Run the upgrade in copy mode
With both servers stopped and the check passed, run the same command again without --check. This keeps the old and new ports explicit and keeps the SQL and log files for review. The default port, 50432, helps avoid an accidental client connection; use different ports if you are checking an old server that is still running:
$ /usr/lib/postgresql/16/bin/pg_upgrade \
--old-datadir "$OLD_DATA" \
--new-datadir "$NEW_DATA" \
--old-bindir "$OLD_BIN" \
--new-bindir "$NEW_BIN" \
--old-port 50432 \
--new-port 50432 \
--jobs 4 \
--retain
Performing Upgrade
Success. Please read the documentation carefully before using the new cluster.
Real output includes more progress and may name generated scripts. A non-zero exit means the upgrade did not finish. If schema restoration fails, read the log, fix the root cause on the old cluster, and use the rollback rules below before trying again. Do not start the new cluster just to see what happens.
6. Apply configuration and verify the new cluster
Restore your intended pg_hba.conf rules and review postgresql.conf, any included files, and postgresql.auto.conf. A permissive rule you added just for the upgrade has no business becoming production policy. Start PostgreSQL 16 through your normal mechanism, connect as the cluster owner, and check the version:
$ psql -X -d postgres -c 'SELECT version();'
version
------------------------------------------------------------------------------------------------------------------
PostgreSQL 16.15 ...
(1 row)
Everything past that is application-specific: check representative databases, extensions, jobs, authentication and replication. Run whatever post-upgrade scripts the log points you to, such as the one that updates extensions, and run vacuumdb --all --analyze-in-stages if your maintenance plan calls for it, so the new planner actually has statistics to work with.
7. Keep the rollback boundary visible
Before the new cluster has started, a failed copy-mode upgrade leaves the old cluster fully usable. Stop and read the logs rather than making ad hoc edits to either data directory. With --check only, nothing about the old cluster changed at all. With --link, the old cluster stays safe to reuse only as long as the new one has never started; the manual may want you to remove the .old suffix from global/pg_control. Follow the generated instructions exactly, and take a backup before you touch that file.
Once the new cluster has started, link mode means it has already written to shared files, so the old cluster stops being a safe rollback target. Restore from a tested backup instead. Never delete the old data directory, or run a generated deletion script, until the new service, applications and backups have all been checked.
Done means
- New binary confirmed as PostgreSQL 16.15 and old and new paths verified.
- Tested backup in hand, both clusters stopped, clients kept away for the whole change.
pg_upgrade --checkpassed with the intended copy, link or clone mode.- Real upgrade completed and its retained logs and generated scripts reviewed.
- PostgreSQL 16 starts with the intended authentication and configuration, and representative queries and extensions work.
- Old cluster retained until the rollback window and backup verification are complete.