pg_upgrade –link finishes a major version bump in seconds, but it still needs the database stopped. I had a service that could not stop.
The tool everyone reaches for on a PostgreSQL major upgrade is pg_upgrade, and for most upgrades it is the right answer. For one PostgreSQL 16 to 17 bump, on a system where the acceptable write outage was measured in seconds rather than minutes, it was not. So I built the new major version as a logical-replication subscriber, let it catch up live against production traffic, verified it, and then flipped. This is how that goes, what logical replication silently refuses to carry, and where I would tell you to stop overthinking it and just run pg_upgrade.
When pg_upgrade is still the right call
pg_upgrade --link hard-links the data files into the new cluster instead of copying them, so the upgrade itself is near-instant regardless of database size. But the old server has to be shut down for the duration, and you are not fully back until you have restarted and re-gathered statistics with vacuumdb --analyze-only, because pg_upgrade does not carry optimiser statistics across. For a service that can absorb a few minutes of downtime in a maintenance window, that is the correct, boring, well-trodden choice. Take it.
Reach for a logical-replication cutover only when one of these is true: your outage budget is smaller than the shutdown-plus-restart window; you want a tested, instant rollback path because the old cluster stays live and writable until you decommission it; or you are changing something else at the same time, such as the storage layer, the operating system, or the region. A migration is a change like any other, and it is worth treating it as one you measure — the same change-failure and lead-time discipline you apply to deployments applies to a database upgrade.
The shape of the cutover
The old cluster (16) is the publisher; the new cluster (17) is the subscriber. Schema does not replicate, so you copy it first, using the new version’s pg_dump binary against the old server so the dump is forward-compatible:
# run from the PostgreSQL 17 host, dumping the 16 publisher
pg_dump --schema-only --no-publications --no-subscriptions \
--host=pg16-old --port=5432 --username=repl app_db > schema.sql
psql --host=pg17-new --port=5432 --username=repl -d app_db -f schema.sql
Then create the publication on the old cluster and the subscription on the new one. The subscription copies the initial table data and then streams live changes on top of it:
-- on pg16-old (publisher)
CREATE PUBLICATION app_pub FOR ALL TABLES;
-- on pg17-new (subscriber)
CREATE SUBSCRIPTION app_sub
CONNECTION 'host=pg16-old port=5432 dbname=app_db user=repl password=...'
PUBLICATION app_pub
WITH (copy_data = true, streaming = on);
-- watch initial sync reach 'r' (ready) for every table
SELECT srrelid::regclass AS table, srsubstate
FROM pg_subscription_rel;
What logical replication silently will not carry
This is where cutovers go wrong, because none of these throw an error — they just quietly leave the new cluster incorrect.
- Sequences. The values in
serialand identity columns replicate as ordinary row data, but the sequence object’s own counter does not advance on the subscriber. Flip without fixing this and your first insert on the new cluster reuses an identifier that already exists. This is the single most common way a logical-replication cutover corrupts data. - DDL. No schema change replicates. Any migration that runs during the catch-up window lands on the publisher only. Freeze schema changes for the window, or apply them to both sides by hand. Enforcing that freeze is easier if your migrations already go through a pre-commit and CI gate rather than ad-hoc
psqlsessions. - Large objects. The
pg_largeobjectcatalogue is not supported by logical replication at all. If you use thelo_interface, those objects need a separate copy step. - Replica identity. Every published table that receives updates or deletes needs a primary key or an explicit
REPLICA IDENTITY; without one, those changes error on the publisher.TRUNCATEdoes replicate, but mind foreign-key order. - Partitioned tables. By default changes publish as the individual leaf partitions, so the partition structure must match on both sides. Set
publish_via_partition_root = trueon the publication if you want changes published against the root instead.
Syncing sequences at the last moment
Do this once, at cutover, after writes have stopped on the old cluster — not before, or the counters drift again. Generate the setval statements from the live publisher and apply them on the new subscriber:
-- on pg16-old, emit setval() calls reflecting current counters
SELECT format(
'SELECT setval(%L, %s, true);',
schemaname || '.' || sequencename,
last_value
)
FROM pg_sequences
WHERE last_value IS NOT NULL;
-- pipe the output straight into psql on pg17-new
If you would rather not hand-roll this, pglogical exists precisely because the in-core feature set leaves sequences and DDL to you: it adds helpers such as synchronize_sequence() and replicate_ddl_command(). For a one-way 16-to-17 hop the built-in logical replication is enough, and one fewer extension in the path is one fewer thing to reason about. Reach for pglogical when you genuinely need its sequence automation or bidirectional flows, not by default.
Verifying parity, then the choreography
Before you trust the new cluster, confirm every table reached srsubstate = 'r', then compare per-table row counts between the two clusters. Counts are not proof of byte-for-byte equality, but they catch gross failures, and a per-table checksum pass catches the rest if the data matters that much. The actual flip is an ordered, rehearsed sequence:
- Stop application writes — a maintenance flag at the app layer, backed by
ALTER DATABASE app_db SET default_transaction_read_only = on;and terminating writer sessions as belt-and-braces. - Wait for the stream to drain: compare
pg_current_wal_lsn()on the publisher againstlatest_end_lsninpg_stat_subscriptionon the subscriber until they meet. - Apply the sequence
setvalstatements. - Run the row-count and checksum verification.
- Repoint the application at the new cluster and lift the read-only flag. If your write path is already safe to retry with idempotency keys, the brief window where requests fail fast and retry costs you nothing.
DROP SUBSCRIPTION app_sub;on the new cluster, and keep the old one running, untouched, for a soak period as your rollback.
The reason to do all of this is not that logical replication is elegant — it is fiddly, and it hands you three or four ways to corrupt data if you skip a step. The reason is that it turns a major-version upgrade into a traffic flip you can rehearse, verify, and reverse, with the old cluster still standing behind you. If you do not need that, pg_upgrade --link is faster, simpler, and correct. Choose the fiddly path only when the downtime you are avoiding actually costs more than the risk you are taking on.
Build and rescue work
Hands-on delivery of this kind is handled by Sixteen Pillars Studio.
Looking at an acquisition, supplier, or major project?
The greatest risks are rarely visible in the executive summary. The Sixteen Pillars framework surfaces the technology risks that diligence usually misses.