Tutorial: Upgrading the Postgres Version In Place with pg_upgrade
If your database is too large for your system's free storage to hold
a second full copy, you can upgrade the node's existing data directory
in place with pg_upgrade.
pg_upgrade migrates a node's existing data directory to a new
PostgreSQL major version in place. Run with --link, it hard-links
the new cluster's files to the old cluster's rather than copying them.
This avoids doubling storage: the new data directory shares disk
blocks with the old one instead of duplicating them.
The trade-off is downtime for that one node. Unlike the rolling
replacement strategy, there is no freshly built node standing by to
take over its traffic while it upgrades - the node goes down, runs
pg_upgrade, and comes back up on the new version before it rejoins
the cluster. The upgrade is zero-downtime for the application only
because the other nodes in the cluster keep serving traffic during
that window; it is not zero-downtime for the node itself.
This tutorial upgrades a three-node cluster (n1, n2, n3) running
PostgreSQL 16 to PostgreSQL 18, one node at a time, without a second full copy
of any node's data. pg_upgrade still needs a new, empty data directory for
each node, but in link mode it holds hard links to the old files rather than
copies of them. When a node also carries a physical standby for high
availability, rebuilding that standby and bringing it under Patroni folds into
the same per-node loop, so high availability is preserved before you move on to
the next node.
Prerequisites and Warnings
Warning
Important Prerequisites and Warnings
- The cluster needs at least three nodes. With one node down for its upgrade, at least two others must remain available to serve the application.
- Your load balancer, connection pooler, or application must be able to stop routing traffic to a node before it goes down for upgrade, and resume routing to it afterward. There is no replacement node to absorb that traffic automatically.
- Disable automatic DDL replication on every node
(
spock.enable_ddl_replication = off), and do not run DDL on the cluster for the duration of the rollout. pg_upgrade --linkrequires the old and new data directories to be on the same filesystem, so that hard links are possible. On a filesystem without hard links, or with the directories on separate volumes,pg_upgradestops withcould not create hard link between old and new data directories; it does not fall back to copying.--linkmode ties the old and new clusters' data files together. After you start the new cluster, you cannot go back to running the old one; treat the oldpostgresql.conf,pg_hba.conf, and any other node-specific configuration as reference material to port forward, not something you can revert to.pg_upgradedoes not carry Spock's replication origins over, on any PostgreSQL version. This procedure records them before the upgrade and restores them afterwards (steps 2 and 19), and drops and recreates the other nodes' subscriptions to the node without copying data (steps 3 and 20).- While a node is down, the other nodes keep WAL for their
subscriptions to it. If
max_slot_wal_keep_sizeis set, make sure it allows for the whole upgrade window, or those slots can be invalidated and the node will need a full rebuild. spock.show_all_subscription_status()andspock.show_all_nodes()are Zodan procedures. Loadzodan.sqlfrom the Spock distribution, which needs thedblinkextension, on the node you run them from.- Run
pg_upgrade --checkagainst the node before running the real upgrade, and resolve everything it reports. - A node's physical standby cannot continue streaming once the node's major version changes; the standby must be rebuilt from the upgraded node, not upgraded in place alongside it.
Note
The examples run every instance on one host and use 127.0.0.1.
When a node and its standby, or the nodes of the cluster, run on
separate hosts, use addresses each host can reach in connection
strings, listen and connect_address, and the etcd URLs, and
point every Patroni member of a scope at the same DCS. Replace the
<...> password placeholders with your own unique secrets.
Upgrading the Cluster with pg_upgrade
Repeat the following steps once per node, moving from n1 to n3 in
our example. Finish all steps for one node before starting the next.
-
Drain traffic from the node being upgraded at the load balancer, pooler, or application layer, and confirm no new connections are arriving at it.
-
Make sure every other node has applied everything
n1committed, and record what the upgrade will lose. With traffic drained, emit a sync event onn1:-- On n1 SELECT spock.sync_event();Wait for it on every other node, passing the LSN it returned. The
resultcolumn must come backt:-- On n2, then on n3 CALL spock.wait_for_sync_event(NULL, 'n1'::name, '<lsn-from-sync_event>'::pg_lsn, 600);Then confirm on
n1that every slot has confirmed past that LSN; this query must return no rows:-- On n1 SELECT slot_name, confirmed_flush_lsn FROM pg_replication_slots WHERE slot_type = 'logical' AND (confirmed_flush_lsn IS NULL OR confirmed_flush_lsn < '<lsn-from-sync_event>'::pg_lsn);A physical slot, such as the one a standby streams through, always has a
NULLconfirmed_flush_lsn, which is why the query is limited to logical slots.pg_upgradedoes not carry Spock's replication origins over, so record where each ofn1's subscriptions has applied up to. Every row must show an LSN; aNULLmeans a subscription with no origin, which you must resolve before upgrading:-- On n1 SELECT s.sub_name, s.sub_slot_name, o.remote_lsn FROM spock.subscription s LEFT JOIN pg_replication_origin_status o ON o.external_id = s.sub_slot_name;Also record, on every other node, the definition of its subscription to
n1, so you can recreate it unchanged in step 20:-- On n2 (sub_n1_n2), then on n3 (sub_n1_n3) SELECT sub_name, sub_replication_sets, sub_forward_origins, sub_apply_delay, sub_force_text_transfer, sub_skip_schema FROM spock.subscription WHERE sub_name = '<subscription-to-n1-on-this-node>';spock.gen_sub_name(provider_node, subscriber_node)names each subscription, sosub_n1_n2is the subscription onn2whose provider isn1. Confirm the names for your cluster withSELECT sub_name FROM spock.subscription;on each node. -
Disable
n1's own subscriptions, so it stops applying, and wait untilSELECT * FROM spock.sub_show_status();onn1no longer shows any asreplicating:-- On n1, once per subscription SELECT spock.sub_disable('sub_n2_n1'); SELECT spock.sub_disable('sub_n3_n1');While
n1is still running, drop every other node's subscription to it.sub_dropremoves the subscription's slot onn1only when it can reachn1; a subscription dropped later leaves its slot behind onn1. No data is lost, because step 2 confirmed that every node had applied all ofn1's changes:-- On n2, then the matching subscription on n3 SELECT spock.sub_drop('sub_n1_n2');If this node already runs under Patroni, pause it so that manually stopping PostgreSQL below does not trigger an automatic restart - Patroni's own documentation names major-version upgrades as the intended use case for this mode.
--waitreturns only once every member has seen the pause, so the stop below cannot race it:patronictl -c /path/to/n1_patroni.yml pause --wait n1-clusterRecord the Spock version the node runs; step 4 needs it, and the server cannot answer once it is down:
SELECT extversion FROM pg_extension WHERE extname = 'spock';Then stop PostgreSQL on the node:
pg_ctl -D /path/to/data/n1 stop -
Install the target PostgreSQL version and Spock on the host, alongside the existing installation; see Installing pgEdge Enterprise Postgres. For PostgreSQL 18 on RHEL, Rocky Linux, or AlmaLinux:
sudo dnf install -y pgedge-postgres18-server pgedge-postgres18-contrib pgedge-spock18On Ubuntu or Debian:
sudo apt install -y pgedge-postgres18-server pgedge-postgres18-contrib pgedge-spock18pg_upgraderecreates the Spock extension in the new cluster at the version you recorded in step 3, and the new server loads the Spock library installed for the target PostgreSQL version. Install that same Spock version for the target version. If the only package for the target version carries a newer Spock, runALTER EXTENSION spock UPDATE;in every database where Spock is installed once the node is up in step 8, before step 19. The other nodes keep their version until their own turn, so the cluster runs mixed Spock versions while the rollout is in progress, as it does in the node-by-node procedure in Upgrading Spock. Finish the rollout on every node before adding a node with Zodan:add_noderequires the new node's Spock version to be the same as or newer than every existing node's. -
Initialize a new data directory on the target PostgreSQL version.
pg_upgrademigrates data into an already-initialized cluster; it does not create one. On a PostgreSQL 16 or later target,pg_upgradecopies the old cluster's encoding and locale settings into the new cluster itself, soinitdbneeds no encoding or locale option. The data checksum setting is not copied and must match the old cluster, orpg_upgradestops. Read it from the stopped data directory:pg_controldata /path/to/data/n1 | grep "checksum version"If the checksum version is
1, enable checksums:/path/to/pg18/bin/initdb -D /path/to/data/n1_new --data-checksumsIf it is
0, disable them explicitly. PostgreSQL 18initdbenables checksums by default, so leaving the option out does not match a cluster without them:/path/to/pg18/bin/initdb -D /path/to/data/n1_new --no-data-checksumspg_upgradedoes not carry configuration files forward; the new data directory has only the defaultsinitdbwrote. Configure it now, before step 6:pg_upgrade --checkreadswal_level,max_replication_slotsandoutput_plugin_librariesfrom the new cluster, and fails if they cannot hold the old cluster's logical slots when the old cluster is PostgreSQL 17 or later. Copypg_hba.confas-is, since authentication rules do not change with the PostgreSQL version:cp /path/to/data/n1/pg_hba.conf /path/to/data/n1_new/pg_hba.confThen add the Spock settings to the new data directory's
postgresql.conf, using the old node's values where they are larger.max_replication_slotsmust be at least the number of logical slots on the old node:wal_level = logical max_worker_processes = 20 max_replication_slots = 10 max_wal_senders = 10 shared_preload_libraries = 'spock' track_commit_timestamp = on output_plugin_libraries = 'pgoutput, test_decoding, spock_output' # PostgreSQL 18 and later only: max_active_replication_origins = 10output_plugin_librariesexists only from the patch versions in the table below. Set it only if the new binaries have it: on an older patch version the server does not recognize the parameter and will not start. Check withSELECT current_setting('output_plugin_libraries', true);, which returns NULL when the parameter does not exist. The following table describes the first patch version of each major version that has it:PostgreSQL major version Minimum patch version 15 15.19 16 16.15 17 17.11 18 18.6 -
Run
pg_upgrade --checkfirst, against the target version's binaries, to confirm there are no blocking issues:/path/to/pg18/bin/pg_upgrade \ --old-datadir /path/to/data/n1 \ --new-datadir /path/to/data/n1_new \ --old-bindir /path/to/pg16/bin \ --new-bindir /path/to/pg18/bin \ --link --checkPass
--linkto the check too, so it also confirms hard links work between the two directories.Then run the upgrade itself, adding
--linkto hard-link the data files instead of copying them:/path/to/pg18/bin/pg_upgrade \ --old-datadir /path/to/data/n1 \ --new-datadir /path/to/data/n1_new \ --old-bindir /path/to/pg16/bin \ --new-bindir /path/to/pg18/bin \ --link -
Port any other settings the node needs from the old
postgresql.conf(memory, logging, connection limits and similar) to the new one before you start it. Do the same for the oldpostgresql.auto.conf, which holds everything set withALTER SYSTEM, and for any file the old configuration pulls in withinclude;pg_upgradecarries none of them over. Review each setting against the target version's documentation rather than copying the files whole, since parameters are renamed or removed between major versions. -
Start PostgreSQL on the new version:
pg_ctl -D /path/to/data/n1_new -o "-p 5432" start -
If this node runs behind a physical standby for high availability, stop it now - physical replication cannot continue once the primary's major version changes. Skip steps 9 through 18 if the node does not use a physical standby.
pg_ctl -D /path/to/data/n1_standby stop -
Install the target PostgreSQL version on the standby's host, alongside its existing installation, the same way you did for the primary in step 4.
Updating Patroni to use the Upgraded Instance
-
If this standby pair already runs under Patroni, you can reuse this node's own existing Patroni cluster and the DCS (Distributed Configuration Store) behind it, rather than standing up either from scratch.
Optional: if this is the first time this node and its standby are being brought under Patroni, install Patroni and a DCS on both hosts first. This tutorial uses
etcdas the DCS, the most common choice for Patroni:pip install patroni[etcd]This installs Patroni's etcd client library, not the etcd server itself. If no etcd cluster already serves the rest of your Spock cluster's Patroni pairs, install and start one. Package name and installation method vary by platform; see the etcd releases page if your package manager does not provide one. A production etcd deployment needs an odd number of members (3 or 5) for quorum; the following configures a single-member etcd suitable for this tutorial:
# /etc/etcd/etcd.yml name: etcd0 data-dir: /path/to/data/etcd0 listen-client-urls: http://127.0.0.1:2379 advertise-client-urls: http://127.0.0.1:2379 listen-peer-urls: http://127.0.0.1:2380 initial-advertise-peer-urls: http://127.0.0.1:2380 initial-cluster: etcd0=http://127.0.0.1:2380 initial-cluster-token: n1-etcd initial-cluster-state: newetcd --config-file /etc/etcd/etcd.ymlConfirm both hosts can reach the DCS before continuing, whether it is freshly started or already serving this pair:
etcdctl --endpoints=127.0.0.1:2379 endpoint health -
Point this node's Patroni configuration at the upgraded instance. If this is the first time the node is being brought under Patroni, write a new config for it as the leader of a new Patroni cluster, rather than letting Patroni bootstrap it from scratch - the data directory is already initialized and already running the data
pg_upgradeproduced, and Patroni only runs its bootstrap process against an emptydata_dir; pointing it at the existing, populated directory makes Patroni adopt the running instance as the cluster's first member instead. If the node was already under Patroni before this upgrade, update its existing config'sdata_dirandbin_dirto the values below instead of writing a new file.pg_upgradepreserves roles and their passwords, and step 5 above already copiedpg_hba.confforward, so areplicatorrole and itspg_hba.confentry from an existing standby still work unchanged.Optional: set a password on the
postgressuperuser only if one is not already set:psql -p 5432 -c "ALTER ROLE postgres WITH PASSWORD '<postgres-password>';"scope: n1-cluster namespace: /service/ name: n1 restapi: listen: 127.0.0.1:8541 connect_address: 127.0.0.1:8541 etcd3: hosts: 127.0.0.1:2379 postgresql: listen: 127.0.0.1:5432 connect_address: 127.0.0.1:5432 data_dir: /path/to/data/n1_new bin_dir: /usr/pgsql-18/bin # /usr/lib/postgresql/18/bin on Ubuntu or Debian parameters: sync_replication_slots: on hot_standby_feedback: on callbacks: on_role_change: /path/to/set_synchronized_standby_slots.sh authentication: replication: username: replicator password: <replicator-password> superuser: username: postgres password: <postgres-password> -
If the node was already under Patroni, its DCS still records the old cluster's system identifier, and
pg_upgradegave the upgraded cluster a new one. Patroni refuses to manage a data directory whose system identifier does not match, so the old cluster state has to go before Patroni can adopt the upgraded instance. Stop the Patroni process on bothn1andn1_standby, then remove the scope's state from the DCS.patronictl removeasks you to type the cluster name and confirm:patronictl -c /path/to/n1_patroni.yml remove n1-clusterThis deletes only the DCS keys for
n1-cluster, not any data directory. It also deletes the scope's dynamic configuration, which ends the pause from step 3 and drops any settings you applied withpatronictl edit-config; record those first withpatronictl -c /path/to/n1_patroni.yml show-config n1-clusterand apply them again once Patroni is running.Whether the node was already under Patroni or is being brought under it for the first time, start Patroni on
n1with the configuration from step 12. With no cluster state in the DCS and PostgreSQL already running on a populateddata_dir, Patroni adopts the running instance as the leader rather than bootstrapping a new one:patroni /path/to/n1_patroni.ymlThen bring up
n1_standbyas a new member: its old data directory holds the old, now-incompatible major version and cannot be reused. Run Patroni on the standby's own host, pointed at the samescopeand DCS, under the member namen1_standby, with an emptydata_dir. Patroni finds the cluster in the DCS and, on first start, runspg_basebackupitself againstn1to populate that empty directory. Ifn1_standbywas already under Patroni, edit its existing config to the values below:scope: n1-cluster namespace: /service/ name: n1_standby restapi: listen: 127.0.0.1:8542 connect_address: 127.0.0.1:8542 etcd3: hosts: 127.0.0.1:2379 postgresql: listen: 127.0.0.1:5442 connect_address: 127.0.0.1:5442 data_dir: /path/to/data/n1_standby_new bin_dir: /usr/pgsql-18/bin # /usr/lib/postgresql/18/bin on Ubuntu or Debian parameters: sync_replication_slots: on hot_standby_feedback: on callbacks: on_role_change: /path/to/set_synchronized_standby_slots.sh authentication: replication: username: replicator password: <replicator-password> superuser: username: postgres password: <postgres-password>patroni /path/to/n1_standby_patroni.yml -
Confirm
n1_standbyjoined and is streaming before moving on:patronictl -c /path/to/n1_patroni.yml list n1-cluster -
Also confirm, on
n1, that the slot backing the standby has synced:SELECT slot_name, synced, failover, invalidation_reason FROM pg_replication_slots WHERE NOT temporary; -
synchronized_standby_slotsnames a Patroni-managed physical slot, and Patroni auto-creates one such slot per standby member - on whichever node is currently primary - named after that standby member rather than after its role. Today that slot is namedn1_standbyand exists onn1; ifn1_standbyis ever promoted, that slot disappears and a new one namedn1appears onn1_standbyinstead, representingn1as the new standby. Theon_role_changecallback named in both Patroni configurations above resets the setting on a future promotion to whichever member is then the standby. Patroni reads its local configuration only at start and on reload, so if you add the callback to a member that is already running, reload it afterwards:patronictl -c /path/to/n1_patroni.yml reload n1-clusterThe following script, deployed on
n1withSIBLING_MEMBERset ton1_standbyandLOCAL_PORTset to5432(and onn1_standbywith them set ton1and5442), setssynchronized_standby_slotsto the other member's name whenever this node becomes the primary:#!/bin/bash # Patroni on_role_change callback. # Patroni invokes this script as: # <script> <action> <role> <scope> # so the role is $2, not $1. # Resets synchronized_standby_slots to the sibling member's name # whenever Patroni promotes this node to primary. ROLE="$2" SIBLING_MEMBER="n1_standby" LOCAL_PORT="5432" if [ "$ROLE" = "master" ] || [ "$ROLE" = "primary" ]; then psql -p "${LOCAL_PORT}" -U postgres -c \ "ALTER SYSTEM SET synchronized_standby_slots = '${SIBLING_MEMBER}';" psql -p "${LOCAL_PORT}" -U postgres -c "SELECT pg_reload_conf();" fi -
The callback only fires on a future promotion, so set the initial value by hand now, on
n1while it is still the leader:psql -p 5432 -U postgres -c \ "ALTER SYSTEM SET synchronized_standby_slots = 'n1_standby';" psql -p 5432 -U postgres -c "SELECT pg_reload_conf();" -
A later promotion of
n1_standbyis a separate event from this setup, and needs its own plan: Spock's subscriptions onn2andn3connect ton1using whichever address was configured when those subscriptions were created, and Patroni does not repoint that address for them. Ifn1is reachable only through a VIP or DNS record that Patroni (orkeepalivedalongside it) moves to whichever member is primary, no further Spock-level action is needed after a promotion. Without such an abstraction in front of the pair, repoint each subscription by hand after a promotion, from any node whose subscription still points at the old address.node_add_interfacewrites only to the local node catalog; it is not replicated. Run it on every node whose subscription you will repoint, heren2andn3, sincesub_alter_interfacelooks the interface up locally:SELECT spock.node_add_interface('n1', 'n1_standby_interface', 'host=127.0.0.1 dbname=inventory port=5442 user=pgedge password=<pgedge-password>');Then run
sub_alter_interfaceon each of those nodes. The subscriptions it names are the ones step 20 recreates, so this only applies once step 20 is done:SELECT spock.sub_alter_interface('sub_n1_n2', 'n1_standby_interface');spock.gen_sub_name(provider_node, subscriber_node)names each subscription, sosub_n1_n2is the subscription onn2whose provider isn1; confirm the exact names for your cluster withSELECT sub_name FROM spock.subscription;on each node before repointing them. -
Put
n1's replication origins back where step 2 recorded them, before its subscriptions run again. For each subscription, create the origin ifpg_upgradedid not keep it, then advance it to the recorded LSN:-- On n1, once per row recorded in step 2 SELECT pg_replication_origin_create('<sub_slot_name>') WHERE NOT EXISTS (SELECT 1 FROM pg_replication_origin WHERE roname = '<sub_slot_name>'); SELECT pg_replication_origin_advance('<sub_slot_name>', '<remote_lsn>'::pg_lsn);Confirm each origin now shows its recorded LSN:
SELECT external_id, remote_lsn FROM pg_replication_origin_status; -
On every other node, recreate its subscription to
n1from the definition recorded in step 2. Do not copy data: step 2 made sure every node already held all ofn1's changes, andn1has taken no writes since. The following example recreates the subscription onn2whose provider isn1:-- On n2, then the matching subscription on n3 SELECT spock.sub_create( subscription_name := 'sub_n1_n2', provider_dsn := 'host=127.0.0.1 dbname=inventory port=5432 user=pgedge password=<pgedge-password>', replication_sets := '<recorded sub_replication_sets>', forward_origins := '<recorded sub_forward_origins>', apply_delay := '<recorded sub_apply_delay>', force_text_transfer := <recorded sub_force_text_transfer>, skip_schema := '<recorded sub_skip_schema>', synchronize_structure := false, synchronize_data := false );Do not set
synchronize_data := truehere. The data sync copies each table into the existing one without emptying it first, so it fails on the rows the node already holds. -
Re-enable
n1's own subscriptions, then confirm every subscription to and fromn1reportsreplicating:-- On n1, once per subscription SELECT spock.sub_enable('sub_n2_n1', true); SELECT spock.sub_enable('sub_n3_n1', true);Then check the status of every subscription in the cluster:
CALL spock.show_all_subscription_status( 'host=127.0.0.1 dbname=inventory port=5432 user=pgedge password=<pgedge-password>', true ); -
Restore traffic to the node at the load balancer, pooler, or application layer.
With n1 upgraded, repeat steps 1 through 22 for n2 and n3.
Each node's standby must be built from a pg_basebackup against that
specific node, never against another one - n2_standby streams from
n2, not from n1, even though Spock keeps their table data
identical.
Hint
A physical standby is tied to the exact system identifier and
Spock catalog rows (its own spock.node/spock.subscription
entries) of the instance it shadows, so it can be promoted into
that instance's exact identity; a standby built from n1 and
promoted in place of n2 would still carry n1's identity and
subscriptions, breaking the mesh rather than replacing n2.
Each node's physical standby, if it has one, also belongs under its
own Patroni scope (n2-cluster, n3-cluster) with its own leader -
never add n2 or n3 as a member of n1-cluster. Patroni elects one
primary per scope, and n1, n2, and n3 each remain independent
primaries in the Spock mesh, not standbys of one another. The DCS
deployment itself can still be shared: one etcd cluster can serve
every node's Patroni scope, so long as each scope's name is unique.
Re-enabling DDL Replication
Once every node is upgraded, turn automatic DDL replication back on, on every node, as in Upgrading Spock:
ALTER SYSTEM SET spock.enable_ddl_replication = on;
SELECT pg_reload_conf();
Verifying the Rollout
When every node has been upgraded, confirm the whole cluster runs the target PostgreSQL version and reports a clean replication topology:
SELECT * FROM dblink(
'host=127.0.0.1 dbname=inventory port=5432 user=pgedge password=<pgedge-password>',
'SHOW server_version'
) AS t(pg_version text);
CALL spock.show_all_subscription_status(
'host=127.0.0.1 dbname=inventory port=5432 user=pgedge password=<pgedge-password>',
true
);
Run the dblink query once per node, changing the port.
The following table describes what to check in the output before considering the rollout complete:
| Field | What to confirm |
|---|---|
| PostgreSQL version | Every node reports the target version. |
| Subscription status | Every subscription between every pair of nodes reports replicating. |
Troubleshooting
-
pg_upgrade --checkreports an incompatible extension version. Install the Spock build for the target PostgreSQL version on the node before upgrading;pg_upgradechecks that every extension in the old cluster has a matching library available for the new one. -
pg_upgrade --linkfails withcould not create hard link between old and new data directories. The old and new data directories are on different filesystems or volumes. Place the new data directory on the same filesystem as the old one, or run without--linkand accept the storage cost of a full copy. -
A subscription on the upgraded node fails because its replication origin does not exist. The origin was not restored before the subscription was enabled. Disable it, restore the origin as in step 19 from the position recorded in step 2, then enable it again.
-
The application still routes traffic to a node that is down for upgrade. Confirm the load balancer, pooler, or application-level routing actually removed the node from rotation in step 1 before you stopped it in step 3, rather than relying on connection failures to redirect traffic.
For more information, review the following resources: