Zero-Downtime Postgres Upgrade: Logical Replication Cutover
PostgreSQL 19 will not ship on schedule. Beta 4 is now targeted for September 24, 2026, and 53 features have already been reverted since the beta cycle opened — including REPACK CONCURRENTLY, SQL/PGQ property graphs, and the postgres_fdw statistics import. The project chose quality over the calendar window, which is the right call for them and an annoying one for everyone who built a roadmap around “we’ll just wait for 19.”
The operational takeaway is blunt: stop waiting. Plan your move onto 17 or 18 now, and plan it so that the move itself costs you a coffee break instead of a weekend. Logical replication is the mechanism that makes that possible.
Two upgrade paths, two very different risk profiles
In-place pg_upgrade --link is the fast path. Hard-link the data files, run the new binaries, refresh planner statistics, done. Downtime is measured in minutes for a few hundred gigabytes, and you avoid copying the heap. The catch: the old cluster is left unusable once the new one starts writing. Your rollback is “restore the backup,” which is exactly the rollback you never want to execute under pressure at 2 a.m.
Logical replication cutover is the boring path, and boring is what you want. You stand up a new-major-version cluster, subscribe it to the old primary, let it sync while production keeps serving traffic, and then cut over once — with a rollback that is a connection-string change, not a restore.
Rule of thumb: use pg_upgrade for a maintenance window you genuinely control. Use logical replication when the application is 24/7, when the schema spans multiple extensions, or when an hour of downtime costs real money.
Sizing the replication topology before you subscribe
Most cutovers that go sideways were under-provisioned, not misconfigured. Logical replication uses three separate worker pools, and they do not talk to each other.
# publisher (current primary, e.g. PG 16)
wal_level = logical
max_wal_senders = 10
max_replication_slots = 10
# subscriber (new primary, e.g. PG 18)
max_logical_replication_workers = 8
max_sync_workers_per_subscription = 4
max_worker_processes = 24
The subtlety worth internalizing: max_logical_replication_workers applies only on the subscriber. On the publisher side, logical decoding runs inside WAL sender processes, which are governed by max_wal_senders. And every apply worker is drawn from the same max_worker_processes pool that also pays for parallel query and any extension background worker. PostgreSQL does not cross-check those two numbers at startup — you find out at CREATE SUBSCRIPTION time, when it is already expensive to be wrong. Budget headroom now; PostgreSQL 19 adds a sequence-synchronization worker per subscription into that same pool.
Rehearse on a clone, and time it to the second
Never let production be the first place the plan runs. Take a physical clone with pg_basebackup or a storage snapshot, restore it into the new-major instance, and rehearse the full sequence:
CREATE SUBSCRIPTION pghtx_mig
CONNECTION 'host=pg16-primary dbname=app user=repl password=...'
PUBLICATION app_pub
WITH (copy_data = true, create_slot = true, enabled = true);
-- watch it drain
SELECT subname, received_lsn, latest_end_lsn FROM pg_stat_subscription;
SELECT slot_name, active, restart_lsn FROM pg_replication_slots;
On the clone, measure three numbers: initial copy duration, steady-state apply lag at your real write volume, and how long the final catch-up takes with writes still flowing. If steady-state lag is minutes rather than seconds, your cutover will be a small outage, and you should know that in advance rather than discover it live.
The cutover runbook
- Pre-flight (days ahead). Confirm the publication covers every table that matters, every table has a primary key or a
REPLICA IDENTITY, and every extension the schema uses is installed on the subscriber.pgvector,pg_partmanand friends must exist before the copy, not after. - Catch-up check. Wait until sub-second lag holds steady for at least a few minutes — not one lucky sample.
- Freeze writes. Set the database or the pooler to read-only. Keeping the old primary writable during cutover is how you create divergent data.
- Drain the last LSN. Confirm the subscriber has replayed everything the publisher sent. Do not move on a stale gauge.
- Reconcile sequences. Sequence state is never replicated.
SELECT setval(...)per sequence frompg_sequenceson the publisher before you let anything write. - Verify. Row counts on your hottest ten tables plus three business smoke queries. Counts match or you do not proceed.
- Repoint and keep a way back. Swap the app connection string, drop the subscription, and leave the old cluster read-only for 24–48 hours. That is your rollback, and it is free.
What actually bites during a live cutover
DDL is not replicated. Schema changes made between the start of the copy and the cutover exist on one side only. Freeze migrations before your sync window, not just before the switch.
Large objects, unlogged tables and some TRUNCATE semantics fall outside logical replication. Audit for them; a handful of app tables that quietly keep huge binary objects in pg_largeobject will happily leave you with a broken new cluster.
Extension upgrades deserve their own window. If you run transparent data encryption, do it deliberately: pg_vault_tde 1.7.1 (PostgreSQL 17 and 18) ships an encryption table access method that wraps every tuple in AES-256-GCM with keys held outside the database in Vault, OpenBao, an HSM or a PKCS wallet. Its 1.7.1 release corrected the AAD derivation for out-of-line TOAST values — with the consequence that TOAST data written by 1.7.0 or earlier does not authenticate under 1.7.1. Affected tables must be exported before the new binary is installed. Sequence that before your cutover, not after.
Heterogeneous sources are a different job. If part of the migration is Oracle or MySQL coming to Postgres, logical replication will not help. PostgreSQL Migrator 1.0 — Dalibo’s stable release, a pure-Go binary with no proprietary dependencies — extracts the source catalog once and scores conversion complexity offline, which is a far cheaper way to find the ugly objects than a first failed attempt at a real cutover.
Monitoring that survives the handover
Cutover day is not the end of the project. The first full vacuum cycle, the first autovacuum storm and the first index bloat report all land in the weeks after. Keep pg_stat_replication, pg_stat_subscription and replication-slot retention under alert from the moment the new primary takes writes, and script every step of the runbook — pgAdmin 4 v9.18, released September 17, is perfectly good for a spot check, but nobody should be clicking a GUI while production waits on the other side of a freeze.
A Houston-specific note on windows
For teams running Postgres behind energy trading and settlement systems, or patient-facing healthcare workloads, the window is the constraint: trading desks need read-write access before the ERCOT day-ahead cycle, and clinical systems cannot be frozen during shift change. The reason the logical-replication path pays for itself here is that the outage is a freeze measured in seconds rather than a server migration measured in hours — and the decision of when to spend that freeze becomes yours to make.
