Tutorial: Updating the Postgres Version with a Rolling Update
Zodan's add_node and remove_node procedures move data between nodes
regardless of which PostgreSQL major version each node runs. Because
of this, the same node-replacement strategy used to roll a Spock
version forward also upgrades PostgreSQL itself. During the upgrade,
you:
- build a replacement node on the target PostgreSQL major version.
- add it to the cluster.
- confirm it is replicating.
- remove the node it replaces.
Since the replacement node is fully live and replicating before the node it replaces is removed, the cluster never drops below its optimal node count and the application sees no interruption - a true zero-downtime rolling upgrade of the PostgreSQL version. When a node also carries a physical standby for high availability, bootstrapping 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.
In this tutorial, a three-node cluster running PostgreSQL 16 (n1,
n2, n3) rolls forward to PostgreSQL 18, becoming n1b, n2b, and
n3b in turn. If you are also moving to a newer Spock version in the
same rollout, the two upgrades combine in a single pass of
add/verify/remove per node.
Warning
Important Prerequisites and Warnings
- Each replacement node should not be accessible to users while it is being added.
- 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. - The users must be identical on the source and the replacement node. Create any users on the replacement node before proceeding; permissions must be identical for all users on both the source and the replacement node.
- Zodan validates that all users from the source node exist on the replacement node before proceeding. This prevents replication failures caused by missing user permissions.
- All nodes in the cluster must be available to Spock for the duration of each add/remove step.
- Run each
add_nodeandremove_nodecall from the node being added or removed. - The
dblinkextension must be installed on the node from whichadd_nodeorremove_nodeis called. - On a node whose PostgreSQL build has the
output_plugin_librariesparameter (the patch versions in Checking PostgreSQL Version Compatibility Before Each Step and later), it must listspock_output, or logical decoding with Spock fails. On an older build the parameter does not exist and must not be set. - If a step fails partway, do not immediately retry. Confirm every artifact the workflow created has been removed first, as described in Troubleshooting below.
add_nodeallows 180 seconds for each wait for a sync event between nodes. If replication lag exceeds this window, the call raises an exception. Confirm network latency and replication lag are within acceptable limits before starting, or raise the budget for the session withspock.sync_timeout.- Do not remove an old node until its replacement reports
replicatingstatus. - Keep at least two nodes running at all times; never remove the second-to-last old node and the first new node in the same window.
- A rolling PostgreSQL update can only move a cluster forward. Unlike a Spock version mismatch, Zodan does not check PostgreSQL version ordering itself - see Checking PostgreSQL Version Compatibility Before Each Step.
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.
Checking PostgreSQL Version Compatibility Before Each Step
Zodan's check_spock_version_compatibility procedure (invoked
automatically by add_node) checks only the Spock extension version;
it does not check the PostgreSQL server version. Before adding
a replacement node, you can confirm its PostgreSQL patch version
number yourself against the source node:
SELECT * FROM dblink(
'host=127.0.0.1 dbname=inventory port=5432 user=pgedge password=<pgedge-password>',
'SHOW server_version_num'
) AS t(source_version int);
SELECT * FROM dblink(
'host=127.0.0.1 dbname=inventory port=5442 user=pgedge password=<pgedge-password>',
'SHOW server_version_num'
) AS t(replacement_version int);
The replacement node's server_version_num must be the same as or
greater than the source node's. The following table describes the
first patch version of each PostgreSQL major version that has the
output_plugin_libraries parameter. On those versions and later it
must list spock_output; on earlier ones leave it unset:
| PostgreSQL major version | Minimum patch version |
|---|---|
| 15 | 15.19 |
| 16 | 16.15 |
| 17 | 17.11 |
| 18 | 18.6 |
Preparing a Replacement Node
Each replacement node needs its own host (or port, on a single host),
data directory, and a PostgreSQL and Spock build at the target
version, prepared before you call add_node. Add the pgEdge
repository to the replacement node's host and install pgEdge
Enterprise Postgres and Spock for the target version, as described in
Installing pgEdge Enterprise
Postgres.
For PostgreSQL 18 on RHEL, Rocky Linux, or AlmaLinux:
sudo dnf install -y pgedge-postgres18-server pgedge-postgres18-contrib pgedge-spock18
On Ubuntu or Debian:
sudo apt install -y pgedge-postgres18-server pgedge-postgres18-contrib pgedge-spock18
In our example, the replacement node runs on its own port on the same
host as the node it replaces; n1b runs on port 5442. Initialize
its data directory on the target PostgreSQL version:
initdb -D /path/to/data/n1b --username=postgres
Before starting the replacement node for the first time, edit the
postgresql.conf file:
- set the settings Spock requires, as in Configuring Spock, using the source node's values.
- size replication origin tracking for the target version.
Hint
shared_preload_libraries only takes effect when the server
starts, so set it now rather than after CREATE EXTENSION spock
has already run.
PostgreSQL 15 through 17 track replication origins with the
max_replication_slots parameter; PostgreSQL 18 and later uses the
max_active_replication_origins parameter. Set the parameter to the
number of subscriptions required:
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 = 10
Copy any spock.* settings the existing nodes use, such as the
conflict resolution settings, so the replacement behaves the same.
Without wal_level = logical, add_node cannot create the slots it
needs on the replacement, and it does not stop: it reports the failure
only as a notice, and only when called with verb := true.
Start Postgres on the replacement node's port, then connect with
psql to confirm the settings took effect:
pg_ctl -D /path/to/data/n1b -o "-p 5442" start
psql -p 5442 -c "SHOW shared_preload_libraries;"
psql -p 5442 -c "SHOW output_plugin_libraries;"
Create the database and user, matching the source node exactly:
psql -p 5442 -c "CREATE DATABASE inventory;"
psql -p 5442 -c "CREATE USER pgedge WITH PASSWORD '<pgedge-password>';"
psql -p 5442 -c "GRANT ALL ON DATABASE inventory TO pgedge;"
Install the Spock extension built against the target PostgreSQL version:
psql -p 5442 -d inventory -c "CREATE EXTENSION spock;"
psql -p 5442 -d inventory -c "CREATE EXTENSION dblink;"
If the node it replaces runs behind a physical standby for high
availability, and the replacement node's target version is PostgreSQL
18 or later, also configure the server's native
sync_replication_slots mechanism before adding it to the cluster -
Spock's own failover worker does not run on PostgreSQL 18+. Both
approaches below need a replication role, a known superuser password,
and a pg_hba.conf entry permitting both to connect; set these up
once, on the replacement node, before choosing a path:
psql -p 5442 -c \
"CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD '<replicator-password>';"
psql -p 5442 -c "ALTER ROLE postgres WITH PASSWORD '<postgres-password>';"
Add entries to /path/to/data/n1b/pg_hba.conf permitting the standby
host to open a replication connection and, if you choose the Patroni
path below, a superuser connection for Patroni's own health checks:
host replication replicator 127.0.0.1/32 md5
host all postgres 127.0.0.1/32 md5
psql -p 5442 -c "SELECT pg_reload_conf();"
Choose one of the following approaches:
- Manage the standby pair through Patroni, as described in step 3 of Rolling the Update Across the Cluster below.
-
Configure the native path by hand:
-
Create a physical replication slot on the replacement node, for its future standby to stream through:
SELECT pg_create_physical_replication_slot('n1b_standby_slot'); -
Take a base backup of the replacement node into the standby's data directory, streaming through that slot:
pg_basebackup -h 127.0.0.1 -p 5442 -U replicator \ -D /path/to/data/n1b_standby -S n1b_standby_slot -X stream -P touch /path/to/data/n1b_standby/standby.signalstandby.signalis what makes the server start as a standby;pg_basebackup -Rwould also write it, but it would put aprimary_conninfowithoutdbnameintopostgresql.auto.conf, which overrides the one set in the next steps, and slot sync needsdbname. -
Set
synchronized_standby_slotsin the replacement node'spostgresql.conf, naming that slot:synchronized_standby_slots = 'n1b_standby_slot' -
On the standby itself, point it at the replacement node and enable native slot sync in its
postgresql.conf:sync_replication_slots = on primary_conninfo = 'host=127.0.0.1 port=5442 dbname=inventory user=replicator password=<replicator-password>' primary_slot_name = 'n1b_standby_slot' hot_standby_feedback = on -
After starting the standby, confirm the slot synchronized:
SELECT slot_name, synced, failover, invalidation_reason FROM pg_replication_slots WHERE NOT temporary;
-
See Logical Slot Failover (HA Standby) for the full reference, including the PostgreSQL 15/16 path and upgrading existing slots.
Rolling the Update Across the Cluster
Repeat the following four steps once per node, moving from n1 to
n3 in our example. Finish all steps for one node before starting the
next.
-
Connect to the prepared replacement node and load the Zodan workflow:
\i /path/to/zodan.sqlHint
Both
zodan.sqlandzodremove.sqlare located in the samples/Z0DAN directory of the Spock GitHub repository.Then add the replacement node, sourcing it from whichever node in the cluster is reachable and already caught up - the node you are replacing is the simplest choice for the first replacement:
CALL spock.add_node( src_node_name := 'n1', src_dsn := 'host=127.0.0.1 dbname=inventory port=5432 user=pgedge password=<pgedge-password>', new_node_name := 'n1b', new_node_dsn := 'host=127.0.0.1 dbname=inventory port=5442 user=pgedge password=<pgedge-password>', verb := true, new_node_location := 'Los Angeles', new_node_country := 'USA', new_node_info := '{"role": "n1-replacement", "pg_version": "18"}'::jsonb ); -
Confirm the replacement node is fully caught up and replicating in both directions before touching the node it replaces:
CALL spock.show_all_subscription_status( 'host=127.0.0.1 dbname=inventory port=5442 user=pgedge password=<pgedge-password>', true );Every subscription involving
n1bshould reportreplicating, including the subscriptions to and fromn2andn3thatadd_nodecreated automatically. -
If the node being retired runs behind a physical standby for high availability, build a matching standby for
n1band bring the pair under Patroni, then cut over to it. Skip this step if the cluster does not use physical standbys.Patroni and a DCS (Distributed Configuration Store) must be installed and reachable from
n1band its future standby host before either node can run under Patroni. This tutorial usesetcdas the DCS, the most common choice for Patroni. Install Patroni on both hosts: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: n1b-etcd initial-cluster-state: newetcd --config-file /etc/etcd/etcd.ymlConfirm
n1band its standby host can reach the DCS before continuing:etcdctl --endpoints=127.0.0.1:2379 endpoint healthBring
n1bunder Patroni as the leader of a new Patroni cluster, rather than letting Patroni bootstrap it from scratch -n1bis already initialized and already running with the dataadd_nodepopulated. Patroni only runs its bootstrap process against an emptydata_dir, so pointing it atn1b's existing, populated directory makes Patroni adopt the running instance as the cluster's first member instead:scope: n1b-cluster namespace: /service/ name: n1b 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:5442 connect_address: 127.0.0.1:5442 data_dir: /path/to/data/n1b 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>sync_replication_slotsandhot_standby_feedbackare the two settings the native slot sync path needs on a standby; set them on bothn1bandn1b_standbyrather than only on whichever is the standby today, since either one can end up in that role after a promotion.synchronized_standby_slotsis deliberately left out ofpostgresql.parametershere - it is set directly on whichever node is primary, by theon_role_changecallback below, and declaring it as a static Patroni-managed parameter would fight that callback: Patroni reconciles managed parameters back to the value in its own configuration, which would undo the callback's update after every promotion.Start Patroni against the running instance:
patroni /path/to/n1b_patroni.ymlThen point a second Patroni instance at the same
scopeand DCS endpoint, under the member namen1b_standby, with an emptydata_dir. Patroni finds the existing cluster in the DCS and, on first start, runspg_basebackupitself againstn1bto populate that empty directory - connecting as thereplicatorrole over the replication connection permitted by thepg_hba.confentry added above, using the credentials in theauthentication.replicationblock below. No manualpg_basebackupstep is needed on this path; Patroni's bootstrap process runs it for you:scope: n1b-cluster namespace: /service/ name: n1b_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:5452 connect_address: 127.0.0.1:5452 data_dir: /path/to/data/n1b_standby 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/n1b_standby_patroni.ymlConfirm
n1b_standbyjoined and is streaming before moving on:patronictl -c /path/to/n1b_patroni.yml list n1b-clusterAlso confirm, on
n1b, that the slot backing the standby has synced before relying on it for failover:SELECT slot_name, synced, failover, invalidation_reason FROM pg_replication_slots WHERE NOT temporary;synchronized_standby_slotsnames a Patroni-managed physical slot, and Patroni's auto-created slots are named after the member, not the role - the slot namedn1b_standbyonly exists whilen1b_standbyis not the primary. 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/n1b_patroni.yml reload n1b-clusterThe following script, deployed on
n1bwithSIBLING_MEMBERset ton1b_standbyandLOCAL_PORTset to5442(and onn1b_standbywith them set ton1band5452), 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="n1b_standby" LOCAL_PORT="5442" 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();" fiThe callback only fires on a future promotion, so set the initial value by hand now, on
n1bwhile it is still the leader:psql -p 5442 -U postgres -c \ "ALTER SYSTEM SET synchronized_standby_slots = 'n1b_standby';" psql -p 5442 -U postgres -c "SELECT pg_reload_conf();"With
n1b_standbystreaming and the callback in place on both nodes, cut over: repoint anything that depended on the old node's standby - a VIP, a DNS record, or a connection string used for read traffic or disaster recovery - at the new Patroni cluster. Leave the old node's standby running until the cutover is confirmed; step 4 removes the old primary, and its standby can be stopped once the rest of the cluster is healthy.A later promotion of
n1b_standbyis a separate event from this cutover, and needs its own plan: Spock's subscriptions onn2andn3connect ton1busing the rawhost=127.0.0.1 port=5442address from theadd_nodecall above, and Patroni does not repoint that address for them. Ifn1bis reachable only through a VIP or DNS record that Patroni (orkeepalivedalongside it) moves to whichever member is primary, pointnew_node_dsnat that stable address instead of a node's raw host and port when you calladd_node, and 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('n1b', 'n1b_standby_interface', 'host=127.0.0.1 dbname=inventory port=5452 user=pgedge password=<pgedge-password>');Then run
sub_alter_interfaceon each of those nodes:SELECT spock.sub_alter_interface('sub_n1b_n2', 'n1b_standby_interface');spock.gen_sub_name(provider_node, subscriber_node)names each subscription, sosub_n1b_n2is the subscription onn2whose provider isn1b; confirm the exact names for your cluster withSELECT sub_name FROM spock.subscription;on each node before repointing them. -
Move application traffic from
n1ton1b, and confirm no new connections are arriving atn1.remove_nodedrops every subscription to and fromn1at once and does not wait for anything, so any write onn1that has not reached the other nodes by then is lost. Whenn1takes no writes, emit a sync event on it:-- On n1 SELECT spock.sync_event();Wait for it on every other node, passing the LSN it returned. The
resultcolumn must come backt:-- On n1b, n2 and n3 CALL spock.wait_for_sync_event(NULL, 'n1'::name, '<lsn-from-sync_event>'::pg_lsn, 600);Then load the removal workflow on the node being retired, and remove it from the cluster:
\i /path/to/zodremove.sqlCALL spock.remove_node( 'n1', 'host=127.0.0.1 dbname=inventory port=5432 user=pgedge password=<pgedge-password>', true );remove_nodedoes not delete the retired node's database, data directory, or logs; stop the instance and reclaim its resources once you confirm the rest of the cluster is healthy.
With n1 replaced by n1b, repeat steps 1 through 4 for n2 and
n3, sourcing each new node from any node already in the cluster -
old or new - that runs a PostgreSQL version no newer than the
replacement node itself.
Re-enabling DDL Replication
Once every node is replaced, 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
After replacing each node, 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=5442 user=pgedge password=<pgedge-password>',
'SHOW server_version'
) AS t(pg_version text);
CALL spock.show_all_nodes(
'host=127.0.0.1 dbname=inventory port=5442 user=pgedge password=<pgedge-password>',
true
);
CALL spock.show_all_subscription_status(
'host=127.0.0.1 dbname=inventory port=5442 user=pgedge password=<pgedge-password>',
true
);
Run the dblink query once per node, changing the port.
show_all_nodes lists the nodes; show_all_subscription_status
reports each subscription's status.
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. |
| Node name | Only n1b, n2b, and n3b remain; no old node is listed. |
| Subscription status | Every subscription between every pair of nodes reports replicating. |
Troubleshooting
-
add_nodefails withcannot synchronize structure from provider running PostgreSQL, or the replacement node's apply worker exits withcannot synchronize structure from upstream server version. The replacement node'spg_dumpcannot dump from a PostgreSQL version newer than itself. The node named as the source runs a newer PostgreSQL major version than the replacement node. Rebuild the replacement node on an equal or newer PostgreSQL major version, or source it from a different node that is not newer than the replacement, and retry. -
Logical decoding fails with
library "spock_output" may not be used as an output plugin. The node's build has theoutput_plugin_librariesparameter and it does not listspock_output. Add it topostgresql.confand runSELECT pg_reload_conf();; no restart is needed. Do not set the parameter on a build that does not have it: an unrecognized parameter stops the server from starting. -
add_nodereports that it could not create a replication slot. Checkwal_level = logicalandmax_replication_slotson the replacement node.add_nodereports a failed slot creation only as a notice, only withverb := true, and carries on; the subscriptions it then creates do not replicate. -
add_nodetimes out waiting for a sync event on a large database. Raise the wait budget for the session withspock.sync_timeoutbefore callingadd_nodeagain. -
A step fails partway through
add_node. Do not retry immediately. Sourcezodremove.sqland callspock.remove_node()on the partially added node first, to clear any subscriptions and slots it created, then retry the add.
For more information, review the following resources: