Skip to main content

How to Upgrade PostgreSQL Major Versions With Almost No Downtime

5 min read
Upgrade my PostgreSQL

We replicate to a new server and keep the old one able to take traffic back.

An in-place major upgrade takes your database down for as long as the upgrade runs, and once it has started there is no way back. Logical replication turns that into a cutover you can measure in seconds, with the old server still consistent and still able to take traffic if the first minute goes badly. The method works because the new server is built and caught up while the old one keeps serving. The risk is entirely in what logical replication declines to carry across, so this guide spends most of its time on sequences, DDL, large objects and tables without a replica identity.

A major version upgrade normally costs a maintenance window that grows with your data. Logical replication changes the shape of that cost. You build the new server while the old one keeps serving, let it catch up over hours or days, and then spend a few seconds pointing the application at it. If the first minute looks wrong, the old server is still running, still consistent, and still able to take traffic back.

PostgreSQL lists replication between different major versions as one of the intended uses of the feature, so this is not a trick. What makes it risky in practice is that logical replication copies row changes and nothing else. The parts it leaves behind are where upgrades go wrong, and they are most of what follows.

Prepare the old server

Logical decoding has to be switched on, and switching it on needs a restart.

# /etc/postgresql/<version>/main/postgresql.conf on the OLD server
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10

max_replication_slots must cover the number of subscriptions you expect plus a reserve for table synchronisation, and max_wal_senders should be at least that number plus any physical replicas already connected. On the new server, max_logical_replication_workers needs to cover one apply worker per subscription plus the table sync workers, and max_worker_processes has to be large enough to hold them.

Create a role that can replicate, allow it in pg_hba.conf, then publish.

-- on the OLD server (the publisher)
CREATE PUBLICATION upgrade_pub FOR ALL TABLES;

A publication covers inserts, updates, deletes and truncates by default.

Find the tables that cannot replicate

A published table needs a replica identity before an update or a delete can be replicated. The default is the primary key. A table with no primary key and no explicit replica identity will let inserts through and then throw an error on the publisher the first time somebody updates a row. Find them before you start, not during the cutover.

-- on the OLD server: tables that will error on UPDATE or DELETE
SELECT c.oid::regclass AS table_name
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p')
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND c.relreplident = 'd'
  AND NOT EXISTS (
    SELECT 1 FROM pg_index i
    WHERE i.indrelid = c.oid AND i.indisprimary
  );

For each one, add a primary key, point the replica identity at a unique index, or fall back to REPLICA IDENTITY FULL. The full option uses the whole row as the key and the documentation is blunt about it being a fallback, because without a suitable index on the subscriber side every update turns into a scan.

Copy the schema by hand

Logical replication does not replicate DDL. The schema has to exist on the new server before the subscription starts, and you have to put it there yourself.

# roles and tablespaces are global objects and are NOT in a pg_dump of one database
pg_dumpall --globals-only --host=old.internal --username=postgres > globals.sql
psql --host=new.internal --username=postgres --dbname=postgres --file=globals.sql

# then the schema of the database itself, without any data
pg_dump --schema-only --host=old.internal --username=postgres --dbname=app > schema.sql
psql --host=new.internal --username=postgres --dbname=app --file=schema.sql

Freeze migrations for the duration. A column added on the old server after this point is not on the new one, and the apply worker will stop the moment a row arrives that mentions it.

Subscribe, then wait

-- on the NEW server (the subscriber)
CREATE SUBSCRIPTION upgrade_sub
  CONNECTION 'host=old.internal port=5432 dbname=app user=replicator'
  PUBLICATION upgrade_pub;

This cannot run inside a transaction block while it is creating the replication slot, and the role running it needs the privileges of pg_create_subscription plus CREATE on the database. The initial copy starts immediately. Watch it finish per table rather than guessing.

-- on the NEW server: every table must reach state 'r' (ready)
SELECT srrelid::regclass AS table_name, srsubstate
FROM pg_subscription_rel
WHERE srsubstate <> 'r';

The states run i for initialize, d while data is copying, f when the copy finished, s for synchronised and r for normal replication. On the old server, keep an eye on how far behind the slot is and on whether the WAL it is holding is still safe.

-- on the OLD server
SELECT slot_name, active, wal_status,
       pg_current_wal_lsn() - confirmed_flush_lsn AS bytes_behind
FROM pg_replication_slots
WHERE slot_type = 'logical';

A wal_status of lost means the slot can no longer be used and you start again. That is the one failure mode that can bite while you are not looking, because an inactive slot keeps WAL on the old server until the disk fills.

What does not come across

Sequence data is not replicated. The values inside serial and identity columns arrive as ordinary row data, but the sequence object on the new server still sits at its start value. Send traffic there and the first insert collides with a row that already exists.

Large objects are not replicated either, and the documentation offers no workaround beyond storing the data in normal tables. Only tables are supported as replication targets, so views, materialized views and foreign tables have to be recreated from the schema dump and, in the case of materialized views, refreshed.

The cutover

Stop writes to the old database. Then check the slot has caught up, fix the sequences, and move.

-- on the OLD server, AFTER writes have stopped: generate the setval statements
SELECT format('SELECT setval(%L, %s, true);',
              schemaname || '.' || sequencename, last_value)
FROM pg_sequences
WHERE last_value IS NOT NULL;

Run that output on the new server. last_value is the last value written to disk, and where a sequence cache is in use it can be ahead of the last value actually handed out, which leaves a gap rather than a duplicate. A gap is harmless. A duplicate is an outage.

Then run ANALYZE on the new database, drop the subscription so the slot on the old server is released, and repoint the application.

-- on the NEW server, once you are happy
DROP SUBSCRIPTION upgrade_sub;

Keep the old server running and untouched for a day. It is the only rollback you have, and it costs nothing to leave it switched on.

If you would rather have this rehearsed on a copy of your data before it is done for real, our migration services cover exactly this kind of cutover, and infrastructure management keeps the result patched afterwards.

Frequently asked questions

How do I upgrade PostgreSQL to a new major version with almost no downtime?

Logical replication from the old server to a new one running the target version keeps the downtime to a few seconds. The new server is built and catches up over hours or days while the old one keeps serving, and the cutover is the moment the application is pointed at it. PostgreSQL lists replication between different major versions as an intended use of the feature. If the first minute goes badly, the old server is still consistent and can take traffic back.

What PostgreSQL settings does logical replication need for a major upgrade?

Set wal_level = logical on the old server, which needs a restart, along with max_replication_slots covering the expected subscriptions plus a reserve for table synchronisation. The max_wal_senders setting should be at least that number plus any connected physical replicas. On the new server, max_logical_replication_workers needs one apply worker per subscription plus the table sync workers, and max_worker_processes has to be large enough to hold them.

Why does a PostgreSQL table without a primary key break logical replication?

A published table needs a replica identity before an update or delete can be replicated, and the default replica identity is the primary key. A table with neither a primary key nor an explicit replica identity lets inserts through, then throws an error on the publisher the first time somebody updates a row. Add a primary key, point the replica identity at a unique index, or fall back to REPLICA IDENTITY FULL, which turns every update into a scan without a suitable index on the subscriber.

Does PostgreSQL logical replication copy the schema to the new server?

No. Logical replication does not replicate DDL, so the schema has to exist on the new server before the subscription starts. Copy roles and tablespaces with pg_dumpall --globals-only, since a pg_dump of one database leaves them out, then the database schema with pg_dump --schema-only, and load both with psql. Freeze migrations until the cutover, because a column added on the old server afterwards stops the apply worker when a row mentioning it arrives.

How do I monitor a PostgreSQL logical replication subscription during an upgrade?

On the new server, query pg_subscription_rel for rows whose srsubstate is not 'r' and wait until none remain, since r means normal replication. On the old server, read wal_status from pg_replication_slots along with the bytes between pg_current_wal_lsn() and confirmed_flush_lsn. A wal_status of lost means the slot can no longer be used and the process starts again. An inactive slot keeps WAL on the old server until the disk fills.

Why do inserts fail with duplicate keys after a PostgreSQL logical replication cutover?

Sequence data is not replicated by logical replication. Serial and identity values arrive as ordinary row data, but the sequence object on the new server still sits at its start value, so the first insert collides with an existing row. After writes stop on the old server, generate setval statements from last_value in pg_sequences and run them on the new server. A sequence cache can leave a gap, which is harmless.

What are the cutover steps for a PostgreSQL upgrade using logical replication?

Stop writes to the old database, confirm the replication slot has caught up, and run the generated setval statements on the new server to fix the sequences. Then run ANALYZE on the new database, drop the subscription with DROP SUBSCRIPTION so the slot on the old server is released, and repoint the application. Keep the old server running and untouched for a day, because it is the only rollback available.

Does PostgreSQL logical replication carry over large objects and views?

Large objects are not replicated, and the PostgreSQL documentation offers no workaround beyond storing that data in normal tables. Only tables are supported as replication targets, so views, materialized views and foreign tables have to be recreated from the schema dump. Materialized views also have to be refreshed once they are recreated.

Read how we handle it in Migration Services.