Black and white photograph of ducks wearing nurse caps in a crowded indoor pen with dozens of ducklings, circa mid-20th century.

max_logical_replication_workers is the size of a process pool. The documentation lists the three kinds of worker that draw from it (four in 19) and stops there, which leaves out the part that matters: only one of those kinds is the one you were counting when you picked the number, and when the pool runs dry nothing fails. A subscription just never finishes.

The default is 4. The context is postmaster, so changing it means a restart, and the range runs from 0 to 262143, the same MAX_BACKENDS ceiling that bounds max_connections. It arrived in PostgreSQL 10 with logical replication itself and the default has not moved since. It applies only on subscribers; a publisher’s side of logical replication runs in WAL senders, which are a different pool under max_wal_senders. Its subscriber-side sibling is max_active_replication_origins, which caps how many subscriptions the server can track; this one caps how many it can actually service.

The pool is shared cluster-wide. pg_subscription is a shared catalog and one launcher serves every database, so a subscription in reporting and one in app are drawing from the same four slots. The slots themselves come out of a bigger pool, max_worker_processes, default 8, which also has to pay for parallel query workers and any extension that registers a background worker. PostgreSQL does not cross-check the two at startup. I set max_logical_replication_workers = 4 over max_worker_processes = 2 on 18.6 and the server started without comment; the complaint arrived later, from a different process, with a different message.

Who is in the pool

The launcher is not. It is a background worker in its own right, registered at startup whenever this parameter is above zero, and it occupies one max_worker_processes slot on every server you run, subscriptions or no subscriptions. (That is where the documentation’s max_logical_replication_workers + 1 comes from.) Its entire job is to wake up, read pg_subscription, and start an apply worker for every enabled subscription that doesn’t have one, at most once per subscription per wal_retrieve_retry_interval.

The leader apply worker is what you were counting: one per enabled subscription, running for as long as the subscription stays enabled. This is the floor. With N subscriptions, N slots are gone permanently, and everything else has to fit in what’s left.

Table synchronization workers do the initial COPY of each table when a subscription is created or when ALTER SUBSCRIPTION ... REFRESH PUBLICATION adds tables. They are started by the apply worker, not the launcher, one per table, up to max_sync_workers_per_subscription (default 2) per subscription, and each exits once its table is copied and caught up, at which point the apply worker marks the table ready. PostgreSQL 19 adds a sequence synchronization worker per subscription to the same pool.

Parallel apply workers, since 16, are what a subscription with streaming = parallel uses to apply a large in-progress transaction while the publisher is still sending it (the publisher’s logical_decoding_work_mem is what decides a transaction is large enough to stream). The leader starts them on demand, up to max_parallel_apply_workers_per_subscription (default 2). The part nobody expects is that they don’t leave. The leader keeps up to half of that limit idle for reuse, so with the default a streaming = parallel subscription holds two slots for the rest of its life after its first large transaction, not one. I checked this on 18.6: one 300,000-row transaction, and twenty seconds after it committed the parallel apply worker was still sitting in pg_stat_subscription with nothing to do.

So the arithmetic the docs summarize as the number of subscriptions “plus some reserve for the table synchronization workers and parallel apply workers” is really this: one slot per subscription, permanently; one more per subscription that uses streaming = parallel, permanently after first use, and a second while a streamed transaction is actually in flight; and max_sync_workers_per_subscription more for every subscription you expect to be initializing at the same time. Add one for the launcher, add whatever parallel query and your extensions need, and that is your max_worker_processes.

What running out looks like

Nothing errors. That is the whole problem. Here is a pool of two on 18.6, with sub1 already replicating three tables and sub2 created a moment ago against the same publication:

1postgres=# SELECT subname, worker_type, pid, relid::regclass FROM pg_stat_subscription;
2 subname | worker_type | pid | relid
3---------+-------------+-------+-------
4 sub1 | apply | 10386 |
5 sub2 | apply | 10414 |
6
7postgres=# SELECT s.subname, srrelid::regclass, srsubstate
8 FROM pg_subscription_rel r JOIN pg_subscription s ON s.oid = r.srsubid;
9 subname | srrelid | srsubstate
10---------+---------+------------
11 sub1 | t1 | r
12 sub1 | t2 | r
13 sub1 | t3 | r
14 sub2 | t1 | i
15 sub2 | t2 | i
16 sub2 | t3 | i

CREATE SUBSCRIPTION sub2 returned success. Its apply worker started, because a slot happened to be free at that instant. Then it tried to start a synchronization worker for its first table and couldn’t, and it never will, because both slots are now held by apply workers that don’t exit. The three tables sit at i (initializing) indefinitely, and the log fills with this, three times every five seconds, from now until someone restarts the server:

1[10414] logical replication apply worker WARNING: out of logical replication worker slots
2[10414] logical replication apply worker HINT: You might need to increase "max_logical_replication_workers".

If the pool was already full before the apply worker itself could start, the same warning comes from the launcher instead, and pg_stat_subscription shows the subscription with a null pid. The other message you might find in the same spot is out of background worker slots, with a hint naming max_worker_processes; that’s the outer pool, and the hint is telling you which of the two to raise. Both are postmaster context. Neither goes up without the restart.

There is one escape that doesn’t need one, and it is worth knowing before you need it: ALTER SUBSCRIPTION ... DISABLE on any subscription you can spare stops its apply worker and frees its slot, and the starved subscription’s next retry picks it up. I did exactly that to sub1 above and sub2’s initial copy began within five seconds.

Even when the pool isn’t wedged, a tight one is slower than the data volume explains. Each refused attempt to start a synchronization worker stamps that table’s retry timer, and the apply worker won’t try that table again for a full wal_retrieve_retry_interval. In my transcript t3 finished copying at :05.6, and t1, which had been refused a slot at :05.5, did not start until :10.7. That’s five seconds of a free slot doing nothing, once for every table that was ever turned away. Multiply by a schema with a few hundred tables and a pool sized for two.

Zero gets its own warning. With max_logical_replication_workers = 0 there is no launcher at all, so nothing ever starts, but nothing tells you that either: CREATE SUBSCRIPTION succeeds, the subscription reports enabled, pg_stat_subscription lists it with a null pid, and the publisher now holds a replication slot that nothing is consuming. That slot retains WAL and pins the catalog xmin on the publisher for as long as it exists, which is the expensive half of the mistake and the half that lands on the other server. (The one legitimate zero is internal: pg_upgrade runs the old cluster with the launcher disabled so no worker can advance a replication origin while the catalogs are being copied.)

The worker_type column in pg_stat_subscription is 17 and later. On 16 you infer it: relid is non-null only for a synchronization worker and leader_pid only for a parallel apply worker, and before 16 there are no parallel apply workers to confuse things. pg_subscription_rel.srsubstate stuck at i under an apply worker that is running is the signature of a starved pool, and backend_type in pg_stat_activity names the launcher and each worker kind separately if you want to compare occupancy against the limit.

Set it once, generously, and before you need it. A slot is a small struct in shared memory and an unused one costs nothing. Four is enough for one subscription and a quiet life. For a migration that creates one subscription per schema so the initial copies run in parallel, which is a good way to run a migration and exactly the setup that finds this limit, do the arithmetic above, round up, and raise max_worker_processes to match. The warning is easy to grep for. The restart it demands is the part you don’t want to discover during the cutover.