Skip to content

Why does pg_upgrade --check fail on logical replication slots?

Last updated

View as Markdown

Quick answer: Unconsumed changes are the usual cause, but they are only one of six conditions. The others concern the new cluster and the output plugin, so you can drain every slot and still fail. Read invalid_logical_slots.txt, which distinguishes a slot that can catch up from one that cannot, and check the new cluster’s settings before the window.

Everything measured on this page was run on 2026-09-20 in one Debian trixie container under Docker 29.1.3, on a host with 8 vCPUs and 62 GB of RAM running Linux 6.17.0-1022-azure. The container carries PostgreSQL 16.15 (Debian 16.15-1.pgdg13+2), 17.11 (Debian 17.11-1.pgdg13+2) and 18.6 (Debian 18.6-1.pgdg13+2), and every upgrade used the 18.6 pg_upgrade binary, as the documentation requires.

“Always run the pg_upgrade binary of the new server, not the old one.” (PostgreSQL 18, pg_upgrade, read on 2026-09-20)

Storage is the container’s own overlay filesystem on local disk. That affects the pg_checksums timings below, but nothing else on this page.

A logical replication slot is a bookmark the server keeps for a consumer. It records the point in the write-ahead log that the consumer has confirmed and prevents the server from discarding later WAL.

From an old cluster at PostgreSQL 17.0 or later, pg_upgrade can attempt to migrate logical slots on a publisher. It can separately migrate subscription dependencies and replication origins on a subscriber. These mechanisms have different conditions, and this page measures both.

The write-ahead log itself is not migrated. The consumer therefore has to be caught up first: the new cluster has no WAL for it to replay.

PostgreSQL lists the publisher-side prerequisites in one place.

“The old cluster has replicated all the transactions and logical decoding messages to subscribers.” (PostgreSQL 18, Upgrading Logical Replication Clusters, read on 2026-09-20)

That is the condition most people know. The table shows what happens when each of the six conditions is violated, one at a time, on the same 17.11 to 18.6 pair. In rows 2 to 6 the slot was fully drained first, so the catch-up condition was satisfied and only the condition under test could fail:

what was wrongthe check line that failedwhat it said
slot had unconsumed changesChecking for valid logical replication slotsinvalid_logical_slots.txt: The slot "slot_behind" has not consumed the WAL yet
new cluster wal_level = replicaChecking for new cluster logical replication slots"wal_level" must be "logical" but is set to "replica"
new cluster max_replication_slots = 0Checking for new cluster logical replication slots"max_replication_slots" (0) must be greater than or equal to the number of logical replication slots (1) on the old cluster
plugin not in new cluster’s output_plugin_librariesChecking for new cluster logical replication slotsYour installation contains logical replication slots with plugins that are not allowed by the new cluster's output_plugin_libraries setting.
plugin shared object absent from the new installationChecking for presence of required librariesloadable_libraries.txt, and the slot check above had already passed ok
new cluster already had a permanent logical slotChecking for new cluster logical replication slotsexpected 0 logical replication slots but found 1

Five of these six checks occur after the consumer has caught up, so draining is necessary but not sufficient.

output_plugin_libraries is an allow-list. It does not indicate whether the plugin is installed. Measured on 2026-09-20 on that 17.11 to 18.6 bench, a drained test_decoding slot against a new cluster whose output_plugin_libraries was set to pgoutput alone failed at Checking for new cluster logical replication slots. The same slot against a new cluster with the default allow-list, but with test_decoding.so moved out of the 18.6 library directory, passed the slot check and failed later at Checking for presence of required libraries.

If you use wal2json or another non-core plugin, installing the extension on the old cluster is not enough. The plugin’s shared library has to be present in the new server’s library directory and allowed by the new server’s allow-list.

“The output plugins referenced by the slots in the old cluster must be installed in the new PostgreSQL executable directory.” (PostgreSQL 18, Upgrading Logical Replication Clusters, read on 2026-09-20)

Measured on 2026-09-20 in that Debian trixie container under Docker 29.1.3, output_plugin_libraries defaults to pgoutput, test_decoding on both 17.11 and 18.6. This is why users of ordinary logical replication do not encounter this check.

“Has not consumed the WAL yet” and “is invalid” are different problems

Section titled ““Has not consumed the WAL yet” and “is invalid” are different problems”

The same file reports both states, and they have opposite fixes. Measured on 2026-09-20 on that 17.11 to 18.6 pair in that Debian trixie container under Docker 29.1.3, a slot whose required WAL had been removed under max_slot_wal_keep_size = 16MB produced:

The slot "slot_dead" is invalid

That slot cannot be drained because its WAL is gone. The options are to drop it and rebuild the consumer or abandon the upgrade. A slot reported as has not consumed the WAL yet is healthy but behind, so waiting is the fix.

The preflight query, and the column that decides it

Section titled “The preflight query, and the column that decides it”

Run this on the old cluster while it is running.

SELECT slot_name,
slot_type,
plugin,
database,
active,
temporary,
restart_lsn,
confirmed_flush_lsn,
wal_status,
invalidation_reason,
conflicting,
CASE WHEN restart_lsn IS NULL THEN NULL
ELSE pg_size_pretty(
pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn))
END AS retained_wal
FROM pg_replication_slots
ORDER BY slot_name;

Measured on 2026-09-20 on a PostgreSQL 17.11 cluster in container dbg-pgu holding one logical slot and one never-connected physical slot, that returns every selected column, expanded here because the row is too wide to read otherwise:

-[ RECORD 1 ]-------+--------------
slot_name | logi
slot_type | logical
plugin | test_decoding
database | postgres
active | f
temporary | f
restart_lsn | 0/14EEDB8
confirmed_flush_lsn | 0/14EEDF0
wal_status | reserved
invalidation_reason |
conflicting | f
retained_wal | 56 bytes
-[ RECORD 2 ]-------+--------------
slot_name | phys
slot_type | physical
plugin |
database |
active | f
temporary | f
restart_lsn |
confirmed_flush_lsn |
wal_status |
invalidation_reason |
conflicting |
retained_wal |

Three details affect how you read a slot inventory.

A physical slot has nothing in these columns. restart_lsn, confirmed_flush_lsn and wal_status are all null on a physical slot that has never been connected to. An inventory query that computes retained WAL as pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) returns null for that row rather than a number, which is why the CASE is there. Physical slots are not part of the pg_upgrade slot check; they belong to streaming replication.

invalidation_reason is the column that answers the question, not conflicting. The documentation names conflicting and nothing else.

“All slots on the old cluster must be usable, i.e., there are no slots whose pg_replication_slots.conflicting is not true.” (PostgreSQL 18, Upgrade, prerequisites for a publisher, read 2026-09-20)

On this bench that column was false for the slot that blocked the upgrade. Measured on 2026-09-20 on that 17.11 cluster, the slot that produced The slot "slot_dead" is invalid read active = f, wal_status = lost, invalidation_reason = wal_removed, conflicting = f. A preflight that tests only conflicting passes a slot that then stops the upgrade. Test invalidation_reason IS NOT NULL, or test whether wal_status is lost.

active = false identifies candidates, not garbage. It is normal between polls for a consumer that connects periodically and permanent for an abandoned one. Before dropping a slot, confirm that no change-data-capture pipeline, backup workflow or failover process depends on it. An inactive slot is not necessarily abandoned, and dropping it commits you to rebuilding whatever was reading it.

Then check what the new cluster will need. These settings belong on the new cluster and are tested against counts taken from the old one:

SELECT current_setting('wal_level') AS wal_level,
current_setting('max_replication_slots') AS max_replication_slots,
current_setting('output_plugin_libraries', true) AS output_plugin_libraries;

The subscriber is a separate upgrade with separate rules

Section titled “The subscriber is a separate upgrade with separate rules”

The page so far is about the publisher. A subscriber has its own conditions, check line and report file.

“All the subscription tables in the old subscriber should be in state i (initialize) or r (ready).” (PostgreSQL 18, Upgrading Logical Replication Clusters, read on 2026-09-20)

Measured on 2026-09-20, with a 17.11 publisher and a 17.11 subscriber and one subscription that had finished its initial copy, srsubstate was r, a replication origin existed, and the 18.6 check reported Checking for new cluster configuration for subscriptions ok and *Clusters are compatible*.

The same pair, with the subscriber stopped part-way through the initial copy of a four million row table, had srsubstate set to d:

Checking for subscription state fatal
Your installation contains subscriptions without origin or having relations
not in i (initialize) or r (ready) state.

and subs_invalid.txt named the table:

The table sync state "d" is not allowed for database:"postgres"
subscription:"s" schema:"public" relation:"big"

The subscriber’s capacity setting differs from the publisher’s, so it is easy to set the wrong one.

“The new cluster must have max_active_replication_origins configured to a value greater than or equal to the number of subscriptions present in the old cluster.” (PostgreSQL 18, Upgrading Logical Replication Clusters, read on 2026-09-20)

Measured on 2026-09-20 on that pair with max_active_replication_origins = 0 on the 18.6 cluster and everything else correct:

Checking for new cluster configuration for subscriptions
"max_active_replication_origins" (0) must be greater than or equal to the
number of subscriptions (1) on the old cluster
Failure, exiting

The version boundary is the dangerous part

Section titled “The version boundary is the dangerous part”

Below PostgreSQL 17.0 there is no check because there is no migration.

“Logical slots on clusters before version 17.0 will silently be ignored.” (PostgreSQL 18, Upgrading Logical Replication Clusters, read on 2026-09-20)

Measured on 2026-09-20, a PostgreSQL 16.15 cluster with a slot holding 5,000 unconsumed rows passed --check with *Clusters are compatible*, and the real upgrade to 18.6 then completed. On the new cluster:

slots_on_new
--------------
0
rows_on_new
-------------
5000

The data arrived. The slot did not, and nothing reported this. A failed check would at least tell you to investigate. Plan the subscriber rebuild yourself before upgrading anything older than 17.0.

Data checksums: a match requirement, not a direction

Section titled “Data checksums: a match requirement, not a direction”

PostgreSQL 18 enables data checksums by default at initdb; 17 did not. Measured on 2026-09-20 in that Debian trixie container under Docker 29.1.3, pg_controldata reports Data page checksum version: 0 on a default 17.11 initdb and 1 on a default 18.6 one. pg_upgrade requires the two to agree, and all four combinations were run:

old cluster 17.11new cluster 18.6result
initdb default (off)initdb default (on)old cluster does not use data checksums but the new one does, Failure, exiting
initdb --data-checksumsinitdb default (on)*Clusters are compatible*
initdb default (off)initdb --no-data-checksums*Clusters are compatible*
initdb --data-checksumsinitdb --no-data-checksumsold cluster uses data checksums but the new one does not, Failure, exiting

This is the first failure for many 17 to 18 upgrades rather than for almost everybody, and the fourth row is the trap in the usual advice. A 17 cluster created with --data-checksums, which is common, fails against a new cluster initialised with --no-data-checksums in the other direction. Read the source cluster’s pg_controldata before choosing the target’s initdb flags.

pg_checksums --enable needs the cluster stopped, and it says so:

pg_checksums: error: cluster must be shut down

“The server must be shut down cleanly before running pg_checksums.” (PostgreSQL 18, pg_checksums, read on 2026-09-20)

It does not rewrite every block. It rewrites the blocks whose checksum has to change. On a fresh enable that is nearly all of them. Measured on 2026-09-20 on 17.11 in that container, two runs:

data directoryblocks scannedblocks writtenelapsedper block
701 MB46,58946,5874.97 s107 µs
2,513 MB190,375190,37313.39 s70 µs

The written count is two short of the scanned count in both runs. The distinction between “scans everything” and “rewrites what must change” is real, but it makes little practical difference when enabling checksums from scratch.

Do not scale the duration by the size of your data directory. Between those two 2026-09-20 runs on 17.11 in that Debian trixie container under Docker 29.1.3, the block count grew 4.1 times and the elapsed time grew 2.7 times on the same host, storage and version. The fixed sync cost is a larger share of the smaller run. Extrapolating linearly from a small rehearsal over-predicted by about 50 percent here, before storage throughput, relation layout, tablespaces, cache state and concurrent activity enter the calculation. Rehearse on production-like storage and time it.

Whether to add checksums is separate from this upgrade. They improve detection of some page corruption, and changing them in a replicated topology has its own rules about doing it consistently across nodes or rebuilding standbys afterwards. Decide it separately and in its own window.

The failure that shows you the wrong error

Section titled “The failure that shows you the wrong error”

If the old cluster has wal_level below logical when pg_upgrade starts it, the visible failure is a connection error:

connection to server on socket "/work/.s.PGSQL.50432" failed:
No such file or directory
could not connect to source postmaster started with the command:
"/usr/lib/postgresql/17/bin/pg_ctl" -w -D "/work/old" -o "-p 50432 -b ..." start
Failure, exiting

pg_upgrade starts the old cluster itself to inspect it. When that start fails, you see the failed connection rather than the reason. The reason was in the log the message points at, one line above the part everybody quotes:

FATAL: logical replication slot "slot_behind" exists, but "wal_level" < "logical"
HINT: Change "wal_level" to be "logical" or higher.

When the temporary postmaster fails to start, read its log before diagnosing the connection error. A connection failure here can also be genuine: socket directory permissions, a socket path too long for the current working directory, a port collision, ownership or authentication. PostgreSQL provides -s for the path-length case specifically.

“In some situations the path name for the current directory might be too long to be a valid socket name. In that case you can use the -s option to put the socket files in some directory with a shorter path name.” (PostgreSQL 18, pg_upgrade, read on 2026-09-20)

The temporary postmaster must start with settings that permit the slot check. A wal_level supplied on the command line by a container supervisor is a common way to lose the setting because pg_upgrade supplies its own arguments. The same failure can come from an unexpected config directory, an include file that is not read or a wrapper that sets environment variables.

The remedy is not limited to putting the setting in postgresql.conf. pg_upgrade passes options through. Measured on 2026-09-20 on that 17.11 to 18.6 pair in that Debian trixie container under Docker 29.1.3, with wal_level set only on the old server’s command line and a slot present, the plain check failed with the connection error above, and this succeeded:

pg_upgrade -b /usr/lib/postgresql/17/bin -B /usr/lib/postgresql/18/bin \
-d /work/old -D /work/new -s /work \
-o '-c wal_level=logical' --check
...
Checking for new cluster logical replication slots ok
*Clusters are compatible*

-o / --old-options and -O / --new-options go to the temporary old and new servers respectively.

pg_upgrade_output.d is created in the directory of the new cluster, not where you ran the command, and it is removed when the run succeeds.

“pg_upgrade creates various working files, such as schema dumps, stored within pg_upgrade_output.d in the directory of the new cluster.” (PostgreSQL 18, pg_upgrade, read on 2026-09-20)

The cleanup explains why a successful rehearsal leaves nothing to compare against:

“pg_upgrade_output.d and its contained files will be removed automatically if pg_upgrade completes successfully; but in the event of trouble, the files there may provide useful debugging information.” (PostgreSQL 18, pg_upgrade, read on 2026-09-20)

Pass -r / --retain if you want the artefacts from a successful run. Three report files appeared on this bench: invalid_logical_slots.txt, loadable_libraries.txt and subs_invalid.txt.

For a slot with a live consumer, start the consumer and let confirmed_flush_lsn stop moving, then stop it and shut the cluster down cleanly. For a slot whose consumer you intend to rebuild anyway, you can consume the pending changes yourself:

SELECT count(*)
FROM pg_logical_slot_get_binary_changes(
'slot_behind', NULL, NULL,
'proto_version', '1', 'publication_names', 'p');

That changes the consumer’s position: those changes are gone from the slot and whatever was meant to receive them will not get them.

Measured on 2026-09-20 on that 17.11 cluster it returned 5,003 changes for 5,000 inserted rows. Decoding the first byte of each message gives 5,000 I for the inserts, one B for begin, one C for commit and one R, a relation message describing the table. The relation message is sent before the first change for each table in a decoding session, so the subscriber knows the column layout before the first row arrives. A change count from a slot is not a row count, and on a transaction touching several tables the gap grows with the number of tables rather than staying at three.

To drop a slot you have decided is finished:

SELECT pg_drop_replication_slot('slot_behind');

One more detail: confirmed_flush_lsn does not have to equal pg_current_wal_lsn() for the check to pass. Measured on 2026-09-20 on that PostgreSQL 17.11 cluster after draining, the slot sat at 0/1637E98 while the shutdown checkpoint written afterwards was at 0/1637F10, 120 bytes further on, and the check passed. pg_upgrade treats a slot as caught up when no decodable changes remain after it. A shutdown checkpoint is not a decodable change, so making those two numbers equal on a live cluster is chasing a moving target.

  1. Read pg_controldata on the old cluster for Data page checksum version, and choose the new cluster’s initdb flags to match it.
  2. Run pg_upgrade --check days before the window, with the transfer mode you intend to use in production, because mode-specific checks exist. --link, --clone and --swap are all in 18.6.
  3. Inventory the slots with the query above. Sort them into catch up, repair or rebuild the consumer, retire and drop, and leave alone.
  4. Set wal_level, max_replication_slots and, for a subscriber, max_active_replication_origins on the new cluster.
  5. Confirm the output plugin’s shared library is installed for the new version and allowed by output_plugin_libraries.
  6. Confirm the new cluster has no permanent logical slots of its own.
  7. For a publisher and subscriber pair: disable the subscriptions, upgrade and start the publisher, then upgrade the subscriber, recreate on the subscriber any publisher tables added in the interval, then enable the subscriptions and refresh the publications. Writes at the publisher during the subscriber’s upgrade are replicated once it is back.
  8. Drain the live consumers, shut down cleanly, check again and only then upgrade.
  9. After the upgrade, run the two commands pg_upgrade prints. Measured on 2026-09-20 on 18.6 in that Debian trixie container under Docker 29.1.3, its closing output was vacuumdb --all --analyze-in-stages --missing-stats-only followed by vacuumdb --all --analyze-only.

Two of those steps change what you can undo. --link leaves the old cluster unsafe to restart once the new one has started, and --swap does so once its file transfer begins. The rollback plan is therefore a decision made at step 2, not at step 8.

Step 2 is the one people skip, and it is free.

The first problem is the generated upgrade runbook. It is the standard pg_upgrade procedure, correct in general, but it has no step for slots because slots are not part of the general procedure. Nothing in “upgrade PostgreSQL from 17 to 18” mentions logical replication.

The second problem is the response to the error. The fastest way to make this check pass is to drop every slot, a suggestion that appears readily and silently commits you to rebuilding every subscriber from a full copy. That may be the right call, but it should be a decision rather than a way to get the check green.

The third problem is the connection error. Given “could not connect to source postmaster”, the natural diagnosis is permissions, sockets or ports, while the real cause is in a log file the message points at and nobody opens.

Alert on retained write-ahead log per slot and on invalidation_reason. The first is the same metric that prevents a full disk. The second tells you that a consumer has passed the point where catching up is possible.

Put wal_level in the configuration file, and know that -o exists when something else starts the server for you.

Run pg_upgrade --check on a restored copy regularly, not once at the start of the window. It is read-only against the old cluster and finds every one of these in advance, including the five that draining does not fix.

Keep a written inventory of slots and who owns each one. The difficult part of this failure is finding out whether anyone still needs the slot. The fix is usually straightforward.

  • Why does pg_upgrade --check fail on logical replication slots?

    Unconsumed changes is the common cause and it is one of six conditions. Measured on 2026-09-20 upgrading PostgreSQL 17.11 to 18.6 in a Debian container under Docker 29.1.3, the check reported 'Checking for valid logical replication slots fatal' and wrote invalid_logical_slots.txt containing 'The slot slot_behind has not consumed the WAL yet'. A fully drained slot on the same bench still failed five other ways.

  • I drained every slot and it still fails. Why?

    Because the other five conditions are about the new cluster and the plugin, not the consumer. Measured on 2026-09-20 on that 17.11 to 18.6 bench with the slot drained, the check still failed for wal_level replica on the new cluster, for max_replication_slots 0, for a plugin missing from output_plugin_libraries, for the plugin shared object being absent, and for one permanent logical slot already existing on the new cluster. Five different messages, printed by two different check lines: four of them under 'Checking for new cluster logical replication slots' and the fifth under 'Checking for presence of required libraries'.

  • What is the first failure most people actually hit?

    Data checksums, when the source has none and the target was initdb'd at the PostgreSQL 18 default. Measured on 2026-09-20 upgrading PostgreSQL 17.11 to 18.6 in a Debian trixie container under Docker 29.1.3, all four combinations: 17 default to 18 default fails, 17 with --data-checksums to 18 default passes, 17 default to 18 with --no-data-checksums passes, and 17 with --data-checksums to 18 with --no-data-checksums fails the other way round. It is a match requirement, not a direction.

  • Why do I get a connection error instead of a useful message?

    Because pg_upgrade starts the old cluster itself and you see the failed connection rather than the cause. Measured on 2026-09-20 upgrading PostgreSQL 17.11 to 18.6 in a Debian trixie container under Docker 29.1.3, with wal_level supplied on the command line rather than in postgresql.conf, the visible error was 'could not connect to source postmaster' and the real one was in pg_upgrade_server.log: 'logical replication slot slot_behind exists, but wal_level < logical'. Passing -o '-c wal_level=logical' made the same check pass.

  • What happens to slots on a cluster older than 17?

    They are ignored, and the upgrade succeeds without mentioning them. Measured on 2026-09-20, a PostgreSQL 16.15 cluster with an undrained slot passed --check and completed a real upgrade to 18.6: all 5,000 rows arrived and pg_replication_slots on the new cluster held zero rows. Nothing warned. Every subscriber has to be rebuilt.