StormaticsStormatics

Upgrading PostgreSQL 9.6 to 17 with pg_upgrade

When you are upgrading across major PostgreSQL versions, there are a few ways to go. Dump and restore is the simplest to reason about, but downtime scales directly with database size, so for anything multi-terabyte, it is off the table. Logical replication gets you near-zero downtime, but it only works from PostgreSQL 10 onward; if your source cluster is on less than version 10, that path does not exist in a native way. That leaves pg_upgrade, the community-maintained tool for in-place major version upgrades. With the –link flag, it creates hard links instead of copying data files, so the upgrade step itself stays fast, no matter how big the database is.

This post is based on an upgrade moving from 9.6 to 17 on Ubuntu using pg_upgrade. I will walk through each phase, flag the things that catch people off guard, and share the validation checks we run after the upgrade completes.

Phase 1: Install PostgreSQL 17 and Prepare the New Cluster

1.1 Install PostgreSQL 17 Binaries

sudo apt update
sudo apt-cache show postgresql-17
sudo apt install postgresql-17 postgresql-client-17 postgresql-contrib-17

# Verify
/usr/lib/postgresql/17/bin/psql --version

1.2 Create the New Data Directory

sudo mkdir -p /pgdata/17/<new_cluster_name>
sudo chown -R postgres:postgres /pgdata/17/<new_cluster_name>/
sudo chmod -R 700 /pgdata/17/<new_cluster_name>

1.3 Initialize the New Cluster

Initialize with the same locale as your existing cluster. Here we are using C.UTF-8.

sudo pg_createcluster 17 <new_cluster_name> \
  --datadir=/pgdata/17/<new_cluster_name> \
  --port=<new_port> \
  --locale=C.UTF-8 \
  --start

Phase 2: Configuration for PostgreSQL 17

Copy the relevant settings from your 9.6 postgresql.conf into the new cluster’s config, but do not blindly copy the whole file. Across the versions between 9.6 and 17, PostgreSQL removed or renamed a significant number of parameters; carrying any of them over will prevent the new cluster from starting. The table below lists every parameter that must be removed or substituted before running pg_upgrade.

Key settings to review and carry forward:

  •       listen_addresses, max_connections
  •       shared_buffers, work_mem, temp_buffers
  •       wal_level, max_wal_size, min_wal_size
  •       archive_mode, archive_command
  •       max_wal_senders, max_replication_slots
  •       statement_timeout, lock_timeout
  •       Timezone settings

Parameters Removed or Renamed Between 9.6 and 17

Critical (PostgreSQL 12+): recovery.conf no longer exists as of PG 12. The server will refuse to start if this file is present. All recovery settings must be moved into postgresql.conf. Standby mode is now indicated by a standby.signal file in the data directory.

Removed in

Parameter

Action Required

PG 10

sql_inheritance

Child table inclusion is now always on (SQL standard)

PG 10

min_parallel_relation_size

Replaced by min_parallel_table_scan_size and min_parallel_index_scan_size

PG 11

replacement_sort_tuples

no longer used

PG 12

standby_mode

Use standby.signal file in PGDATA instead

PG 12

trigger_file

Renamed to promote_trigger_file (then removed in PG 16 > use pg_ctl to promote)

PG 13

wal_keep_segments

Renamed to wal_keep_size (value now in MB, not file count; multiply old value × 16)

PG 14

vacuum_cleanup_index_scale_factor

Ignored since PG 13.3

PG 14

operator_precedence_warning

PG 9.5 transition warning no longer needed

PG 15

stats_temp_directory

Statistics system rewritten in PG 15

PG 16

vacuum_defer_cleanup_age

Made redundant by replication slots and hot_standby_feedback

PG 16

promote_trigger_file

use pg_ctl promote or pg_promote() function

PG 16

force_parallel_mode

Renamde to debug_parallel_query

PG 17

old_snapshot_threshold

snapshot-too-old feature removed

PG 17

trace_recovery_messages

no longer needed

PG 17

db_user_namespace

per-database user simulation removed

Notable Default Value Changes

  • password_encryption: Default changed from md5 to scram-sha-256 in PG 14. Update pg_hba.conf auth methods accordingly and rotate passwords if clients support SCRAM.
  • log_checkpoints: Default changed to “on” in PG 15. Increases log volume on quiet servers; set explicitly to avoid surprises.

Phase 3: Pre-Upgrade Steps

3.1 Remove Incompatible Extensions

Some extensions cannot survive a major version upgrade in-place and must be dropped before pg_upgrade runs. In the current setup, we have repmgr. If you leave it in place, the upgrade check will fail.

# Stop repmgrd first, then drop the extension
sudo systemctl stop repmgrd
sudo -u postgres psql -p <old_port> -d repmgr \
  -c "DROP EXTENSION repmgr CASCADE;"

After the upgrade, repmgr can be reinstalled and reconfigured against the new cluster.

3.2 Stop Both Clusters

pg_upgrade requires both clusters to be fully stopped, not just the old one. If the new cluster is still running from the initialization step, stop it now. Make sure all application traffic pointing at the old cluster is halted as well.

sudo pg_ctlcluster 9.6 <old_cluster_name> stop
sudo pg_ctlcluster 17 <new_cluster_name> stop

3.3 Copy pg_hba.conf

sudo cp /etc/postgresql/9.6/<old_cluster_name>/pg_hba.conf \
        /etc/postgresql/17/<new_cluster_name>/pg_hba.conf

Phase 4: Running pg_upgrade

4.1 Dry Run (–check)

Always run the check mode first. It validates compatibility without touching any data. Do not skip this step; it is fast, and it catches configuration issues before your maintenance window begins.

sudo su - postgres
cd /tmp/

/usr/bin/time -f "Check time: %E" \
  /usr/lib/postgresql/17/bin/pg_upgrade \
  --old-datadir=/pgdata/9.6/<old_cluster_name> \
  --new-datadir=/pgdata/17/<new_cluster_name> \
  --old-bindir=/usr/lib/postgresql/9.6/bin \
  --new-bindir=/usr/lib/postgresql/17/bin \
  --check

If the output ends with “Clusters are compatible”, you are clear to proceed.

4.2 Run the Upgrade

The –link flag uses hard links instead of copying files, which makes the upgrade significantly faster for large clusters. The –jobs flag parallelizes the catalog conversion work across multiple CPU cores.

/usr/bin/time -f "Upgrade time: %E" \
  /usr/lib/postgresql/17/bin/pg_upgrade \
  --old-datadir=/pgdata/9.6/<old_cluster_name> \
  --new-datadir=/pgdata/17/<new_cluster_name> \
  --old-bindir=/usr/lib/postgresql/9.6/bin \
  --new-bindir=/usr/lib/postgresql/17/bin \
  --jobs=8 \
  --link

Note: Because hard links point to the same underlying data blocks, the old cluster’s data directory is considered tainted after the upgrade completes. Do not start the old cluster again. If you need a rollback path, take a filesystem snapshot before running the upgrade. Also, Set –jobs based on the number of available CPU cores, but leave some headroom if the system is also serving live traffic during the cutover window.

Phase 5: Post-Upgrade Tasks

5.1 Start the New Cluster

sudo pg_ctlcluster 17 <new_cluster_name> start

5.2 Run analyze_new_cluster.sh

pg_upgrade generates a helper script to rebuild optimizer statistics, which are not transferred during the upgrade. Run this before routing any traffic to the new cluster as without fresh statistics, the query planner will make poor decisions.

sudo su - postgres
PGPORT=<new_port> /usr/bin/time -f "Analyze time: %E" ./analyze_new_cluster.sh

If a specific table fails due to a timeout, run it manually:

sudo -u postgres psql -p <new_port> -d <your_database> -c "
SET statement_timeout = 0;
ANALYZE VERBOSE <schema>.<table_name>;"

Note 1: This step is specific to pg_upgrade’s behavior through PG17. Starting with PostgreSQL 18 optimizer statistics (table and column-level stats from pg_stats) transfer automatically during the upgrade; analyze_new_cluster.sh is no longer needed for that purpose. 

Note 2: A full reindex isn’t required for every upgrade. Hash indexes specifically must be rebuilt after upgrading from any version before 10; pg_upgrade will generate a script flagging any it finds. Using concurrently to rebuild index in that case is the safer side. 

Phase 6: Post-Upgrade Validation

Before routing any application traffic to the new cluster, run through these integrity checks. These are the queries I use after every major version upgrade.

Check the version

SELECT version();

Check for invalid indexes

This query lists any indexes left in an invalid state that failed or was interrupted, or a partial rebuild during the upgrade itself. An invalid index is silently skipped by the planner, so queries still run but without the performance benefit you expect. This should return zero rows. Any invalid index needs to be rebuilt before going live.

SELECT s.schemaname, s.relname AS tablename, s.indexrelname AS indexname
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE i.indisvalid = false;

Check extension versions

pg_upgrade carries over your installed extensions, but it doesn’t upgrade them to match what’s bundled with the new PostgreSQL binaries. This query compares what’s installed against what’s available and flags any mismatch. Look for anything marked NEEDS UPDATE and upgrade those extensions (ALTER EXTENSION … UPDATE) before proceeding.

SELECT name, default_version, installed_version,
  CASE WHEN default_version != installed_version
    THEN '*** NEEDS UPDATE ***'
    ELSE 'OK'
  END AS status
FROM pg_available_extensions
WHERE installed_version IS NOT NULL
ORDER BY name;

Check the logs

tail -f /var/log/postgresql/postgresql-17-<new_cluster_name>.log

Phase 7: Cleanup

Once the new cluster has been stable for a defined retention period, remove the old cluster to reclaim disk space.

./delete_old_cluster.sh

Or manually:

sudo pg_dropcluster 9.6 <old_cluster_name> --stop
sudo rm -rf /pgdata/9.6/<old_cluster_name>/

Wrapping Up

The core pg_upgrade step with –link is fast for most clusters; you are looking at single-digit minutes of actual downtime regardless of database size. The work is really in the preparation: cleaning up incompatible extensions, getting the configuration right, testing application compatibility and making sure you have proper backup before you begin.

The analyze and reindex phases are what take time post-upgrade. Run them before routing application traffic; especially the analyze, a query planner with stale statistics will cause real problems. Once those are done and your validation queries come back clean, you are good to go.

Leave A Comment