Skip to main content
RunBook Academy

PostgreSQLXVII · Capacity, Maintenance and UpgradesMaintenance

pg_upgrade in practice

Advanced⏱ ~35 min🧪 Lab requiredpg_upgradepsql

What you'll learn

  • Run pg_upgrade end to end with the correct sequence
  • Use --check as a pre-flight days in advance
  • Know what survives the upgrade and what does not
  • Complete the post-upgrade steps that are not optional

Prerequisites

Practice

Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27

Not yet marked complete on this device.

Everything below was performed on a cluster running PostgreSQL 17.11, upgraded to 18.6, with both sets of binaries installed on one host.

What must exist first

Both major versions’ binaries, on the same machine. pg_upgrade runs the old server and the new one. On Debian that means postgresql-17 and postgresql-18 installed together.

A new, initialised, empty cluster. initdb for the new version, with the same locale and encoding as the old, or the upgrade refuses.

Every extension installed for the new version, before you start.

A verified backup. Part XIII.

--check first, days in advance

Read-only / Safepg_upgrade --check, with the old cluster STILL RUNNING
$ pg_upgrade --old-datadir=/var/lib/postgresql/17data \
          --new-datadir=/var/lib/postgresql/18data \
          --old-bindir=/usr/lib/postgresql/17/bin \
          --new-bindir=/usr/lib/postgresql/18/bin --check
Checking for objects affected by Unicode update               ok
Checking for not-null constraint inconsistencies              ok
Checking for presence of required libraries                   ok
Checking database user is the install user                    ok
Checking for prepared transactions                            ok
Checking for new cluster tablespace directories               ok

*Clusters are compatible*

--check ran against a live old cluster. That is what makes it usable as a pre-flight days or weeks ahead, and it is the step that finds the problems you do not want to find during the window — missing extension libraries above all.

The real run refuses a running cluster

Service impact possibleattempting the upgrade without stopping the old cluster
$ pg_upgrade --old-datadir=... --new-datadir=... --old-bindir=... --new-bindir=...
There seems to be a postmaster servicing the old cluster.
Please shutdown that postmaster and try again.
Failure, exiting

Stop it cleanly — pg_ctl stop -m fast — and confirm.

The upgrade

Destructive17.11 to 18.6, copy mode
$ pg_upgrade --old-datadir=... --new-datadir=... --old-bindir=... --new-bindir=...
Setting next transaction ID and epoch for new cluster         ok
Resetting WAL archives                                        ok
Setting frozenxid and minmxid counters in new cluster         ok
Restoring global objects in the new cluster                   ok
Restoring database schemas in the new cluster                 ok
Copying user relation files                                   ok
Setting next OID for new cluster                              ok
Sync data directory to disk                                   ok
Creating script to delete old cluster                         ok
Checking for extension updates                                ok

Upgrade 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-only
Running this script will delete the old cluster's data files:
  ./delete_old_cluster.sh

ELAPSED: 2 seconds

Note Resetting WAL archives. The new cluster starts a fresh WAL sequence, which means your archive and every standby are now irrelevant to it. Standbys must be rebuilt, and archiving reconfigured — which lesson XVII-08 puts in the post-upgrade checklist.

What survived, measured

Read-only / Safeplanner statistics, before and after
$ SELECT tablename, attname, n_distinct, null_frac, correlation FROM pg_stats WHERE tablename='orders';
--- BEFORE, on 17.11 ---
tablename | attname  | n_distinct | null_frac |  correlation
-----------+----------+------------+-----------+---------------
orders    | amount   |   -0.20237 |         0 | -0.0049658697
orders    | customer |       9980 |         0 |   0.008761019

--- AFTER, on 18.6 ---
orders    | amount   |   -0.20237 |         0 | -0.0049658697
orders    | customer |       9980 |         0 |   0.008761019

Identical to the last digit. The planner statistics crossed the major version boundary.

Read-only / Safecumulative statistics, after
$ SELECT relname, last_analyze, last_autoanalyze, n_live_tup FROM pg_stat_user_tables WHERE relname='orders';
 relname | last_analyze | last_autoanalyze | n_live_tup
---------+--------------+------------------+------------
orders  |              |                  |          0

Nothing. pg_stat_user_tables starts from zero, which means autovacuum’s thresholds start from zero too.

After the upgrade

Not optional, in this order:

# 1. Start the new cluster and confirm what it is
psql -c "SELECT version();"

# 2. Fill in the statistics that did not transfer
vacuumdb --all --analyze-in-stages --missing-stats-only
vacuumdb --all --analyze-only

# 3. Verify data
psql -d appdb -c "SELECT count(*) FROM orders;"

# 4. Rebuild standbys — the WAL sequence was reset
# 5. Reconfigure archiving to a new location
# 6. Check extensions
psql -c "SELECT extname, extversion FROM pg_extension;"
# 7. Update monitoring for changed views (lesson XVI-03)

What to take from this

  • Both versions’ binaries, an empty new cluster with matching locale, extensions installed, and a verified backup.
  • --check runs against a live old cluster. Use it days ahead.
  • The real run refuses a running old cluster.
  • Measured: 2 seconds for 78 MB in copy mode. Mechanism, not a projection.
  • Planner statistics survived identically; cumulative statistics did not. 17 said “Optimizer statistics are not transferred”; 18 says “Some”.
  • Resetting WAL archives means standbys must be rebuilt and archiving reconfigured.
  • Run vacuumdb --analyze-in-stages --missing-stats-only afterwards.
  • Keep delete_old_cluster.sh unrun for days, and take a new base backup as soon as you have verified.

Cross-course references

  • Linux for Production Sysadmins — Part XI (Package management) covers having two majors installed at once, which pg_upgrade requires and some packagings make awkward.
  • Ansible for Production Sysadmins — Part XXIV (Assertions and guardrails) covers refusing to proceed when a precondition is unmet, which is what --check is doing.

Quiz

Knowledge check · 6 questions

  1. Q1. After a pg_upgrade to 18, pg_stats shows the same n_distinct and correlation values as before, but pg_stat_user_tables shows n_live_tup of zero and no last_analyze. What is going on?

  2. Q2. Why must standbys be rebuilt after a pg_upgrade rather than simply repointed at the new cluster?

  3. Q3. Which pg_upgrade step is where failures almost always occur, and why?

  4. Q4. Which must be true before running pg_upgrade? Select all that apply.

  5. Q5. pg_upgrade --check can be run against a live old cluster without downtime.

  6. Q6. Why should a new base backup be taken immediately after a verified pg_upgrade?

Passing score: 75%. Answers are checked in this browser.