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.

4.3 Debian/Ubuntu Alternative: Using pg_upgradecluster

If you’re using PostgreSQL packages from Debian-based distributions (such as Debian or Ubuntu), you can simplify the upgrade process by using the pg_upgradecluster utility provided by the distribution.

To perform an in-place upgrade using hard links:

 
pg_upgradecluster -m link <old_version> <cluster_name>
 

The -m link option performs a hard-link upgrade similar to pg_upgrade --link, significantly reducing both upgrade time and disk space requirements.

If your filesystem supports reflinks (such as Btrfs or reflink-enabled XFS on Linux), you can instead use:

pg_upgradecluster -m clone <old_version> <cluster_name>
 

The -m clone option creates copy-on-write clones instead of hard links. This provides performance comparable to a hard-link upgrade while preserving the original PostgreSQL data directory, making rollback easier if needed.

Note:pg_upgradecluster is available only on Debian-based distributions. The -m clone option requires filesystem support for reflinks. The standard pg_upgrade workflow described in this guide remains the recommended approach for environments using PostgreSQL community packages or other Linux distributions.


Phase 5: Post-Upgrade Tasks

5.1 Start the New Cluster

sudo pg_ctlcluster 17 <new_cluster_name> start

5.2 Regenerate optimizer statistics

In PostgreSQL 14 and later, it no longer generates an analyze_new_cluster.sh script. pg_upgrade now prints the command directly and expects you to run it yourself. Run this before routing any traffic to the new cluster; without fresh statistics, the query planner will make poor decisions.

sudo su - postgres
PGPORT=<new_port> /usr/bin/time -f "Analyze time: %E" vacuumdb --all --analyze-in-stages

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 applies to PG14 through PG17. Starting with PostgreSQL 18, optimizer statistics transfer automatically during the upgrade, so this step 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.

Comments (6)

  • ALEXANDRE DE ARRUDA PAES

    July 17, 2026 - 9:49 am

    Hi,

    Additional info to your article:

    Using packages from debian like distros, you can use pg_upgradecluster -m link oldversion oldname [new datadir]
    If your filesystem have reflinks enabled(i.e. modern xfs, btrfs, zfs) you can use -m clone instead link and you will preserv the old installation intact,with the same velocity of hardlinks.

    Best regards,

    Alexandre

    • Shridhar Khanal

      July 20, 2026 - 5:22 am

      Hi Alexandre,

      Thank you for taking the time to read the article and for sharing your suggestion.

      I’ve updated the blog to include the additional information for Debian/Ubuntu users as an optional approach alongside the standard pg_upgrade workflow.

      I appreciate your feedback and contribution.

      Best regards,
      Shridhar

  • Peter Cooper

    July 21, 2026 - 7:15 pm

    I think analyze_new_cluster.sh hasn’t been generated by Postgres 14 onwards or so and it just gives you the vacuum command to run instead?

    • Shridhar Khanal

      July 24, 2026 - 3:59 pm

      Hi Peter,

      Thanks for pointing that out!

      You’re absolutely right. I have updated the article to reflect this change for PostgreSQL 14+.

      Thanks again for helping improve the article!

      Best Regards,
      Shridhar Khanal

  • ram krishan Mishra

    July 23, 2026 - 12:30 pm

    Hi, I want to suggestion about postgres native deployed in rhel versions then can work the same steps?

    • Shridhar Khanal

      July 24, 2026 - 4:09 pm

      Hi Ram,

      Thank you for your comment.

      The core pg_upgrade process is the same on RHEL. This guide is written specifically for Debian/Ubuntu, so the installation, service management, and configuration paths differ on RHEL. I will consider publishing a RHEL-specific version of this guide in the future.

      Thanks,
      Shridhar Khanal

Leave A Comment