Replication and WAL7 min read

PostgreSQL Logical Replication Conflicts: Stalls, Causes, and Fixes

A single unique violation on a logical replication subscriber can stall the stream and fill the publisher's disk with retained WAL. Here is how conflicts happen and how to recover.

The alert said replication lag, which is usually a network or capacity story. This one was not. The subscriber's apply worker had died on a unique violation hours earlier and was restarting, retrying, and failing again in a tight loop, while the lag metric climbed and, on the publisher, the replication slot pinned every byte of WAL generated since the stall began. By the time anyone looked, the publisher's WAL directory had eaten most of its free disk, and we were an hour away from the far worse incident where the publisher runs out of space entirely. Logical replication is wonderful machinery, but its conflict behavior is unforgiving: one row in the wrong place stops the stream, and the meter runs until you fix it.

This post is the field guide I wish someone had handed me before that night: what an apply conflict actually is, the ways they happen in real systems, how to see one coming in the catalogs, and how to resolve it without tearing down the subscription.

What an apply conflict is

Logical replication, in PostgreSQL since version 10, streams row-level changes from publications on a publisher to subscriptions on a subscriber. On the subscriber, an apply worker receives each change and executes the equivalent INSERT, UPDATE, or DELETE against the local table. A conflict is any case where that local statement cannot be applied cleanly: an INSERT whose primary key already exists, or any other constraint violation against local data. One case is explicitly not a conflict: a replicated UPDATE or DELETE whose target row is missing on the subscriber is simply skipped, and the stream moves on. When a real conflict happens, the apply worker errors out. The subscription does not advance past the failing change, the worker restarts and hits the same change again, and the stream stays stuck behind a transaction that will never succeed on its own.

The stall is bad enough on its own. The second-order effect is what hurts: the publisher keeps a logical replication slot for the subscription, and a slot retains all WAL generated after the last confirmed position. A stalled subscription means WAL accumulates on the publisher until the conflict is resolved or the disk fills, whichever comes first. Treat conflict errors as storage emergencies, not replication trivia.

The usual causes

  • Writes on the subscriber. The subscriber is supposed to be read-only for replicated tables. Any local insert that later collides with a replicated insert produces a unique violation. An application pointing a writer at the wrong host is the classic cause.
  • Pre-existing rows when adding a table. Adding a table to a subscription copies existing data first. If the subscriber already held some of those rows, or rows land on both sides during the catch-up window, the initial sync or the streaming phase collides with them.
  • Sequences after a failover. Sequences are not replicated. If you promote a subscriber and applications start taking nextval from sequences that lag behind the old publisher's, the rows they insert will conflict when you rebuild replication in the other direction. Advancing sequences belongs in every failover runbook.
  • Triggers firing on the subscriber. The apply worker runs with session_replication_role set to replica, so default triggers stay quiet; only triggers configured ENABLE REPLICA or ENABLE ALWAYS fire during apply. If one of those writes into the same or another replicated table, it creates rows the publisher will later also send. Audit subscriber triggers and leave them at the default ENABLE ORIGIN unless firing during apply is the actual intent.
  • Bidirectional replication loops. Two nodes publishing and subscribing to each other can re-apply each other's changes unless you filter origins, which is what PostgreSQL 16's origin option is for.

Seeing the conflict before the disk fills

On the subscriber, pg_stat_subscription shows the apply workers and what they are doing, and since PostgreSQL 15, pg_stat_subscription_stats counts apply and sync errors per subscription, which turns "is this subscription erroring" into a number you can alert on rather than a log line you hope someone reads:

SELECT subname, apply_error_count, sync_error_count,
       stats_reset
FROM pg_stat_subscription_stats;

The error itself, with the conflicting key spelled out, lands in the subscriber's log. On the publisher, watch what the slot is retaining:

SELECT slot_name, active,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
       ) AS retained_wal
FROM pg_replication_slots;

A growing retained_wal figure on an inactive or lagging slot is your early warning, and it is the single metric I would alert on if I could only pick one. As a backstop, max_slot_wal_keep_size bounds how much WAL any slot may retain; be clear-eyed about the tradeoff, because when the limit is exceeded the slot is invalidated and the subscription must be rebuilt. That trades a broken subscription for a living publisher, which is usually the right trade, but it is still a trade you should make consciously rather than discover.

Resolving without breaking the stream

The ordinary fix is boring and safe: read the error, find the conflicting row, and make the subscriber's data consistent with the stream. For a unique violation on insert, that usually means deleting or adjusting the pre-existing local row on the subscriber so the replicated insert can apply. The apply worker retries continuously, so the moment the obstruction is gone the stream resumes from where it stalled. You do not need to recreate anything, and you should not: dropping and re-adding the subscription means a full initial copy of the table, which on a large table is its own incident.

Occasionally the right call is to skip the offending transaction instead of fixing the row, for example when the subscriber's local row is the one you want to keep. Since PostgreSQL 15, ALTER SUBSCRIPTION ... SKIP advances the subscription past a specific transaction LSN without applying it:

ALTER SUBSCRIPTION analytics_sub
  SKIP (lsn = '0/16B6C50');

The LSN to skip comes from the error context in the subscriber log. Skipping is a divergence decision: the subscriber will permanently not have that transaction's changes, so use it when divergence is the intent, not as a way to make the alert go quiet. PostgreSQL 16 added a gentler option for non-critical subscribers, the disable_on_error subscription flag, which disables the subscription on an apply error instead of hot-looping. The WAL still accumulates, but the log spam stops and the failure becomes a clean disabled state someone can triage calmly.

Preventing the next one

Most prevention is hygiene. Enforce read-only access to replicated tables on the subscriber with permissions, not convention. If you run any multi-writer or failover-capable topology, partition sequence space per node with different increments or ranges so a promoted node cannot mint colliding keys. For bidirectional replication on PostgreSQL 16 and later, create subscriptions WITH (origin = none), which applies only changes that carry no replication origin, breaking the echo loop where a change applied from node A gets re-published back to node A by node B. And if you are weighing how different managed PostgreSQL offerings expose logical replication and its failure modes, our comparison pages cover the operational differences that matter.

Watching the stream with MonPG

Of everything in this post, retained WAL on the publisher is the metric I would page on first, because it converts a replication nuisance into a storage countdown. MonPG already tracks production PostgreSQL replication, covering slot activity, retained WAL, subscription worker state, and lag, right next to the query and vacuum signals that usually explain why a slot fell behind in the first place. The PostgreSQL monitoring page shows the workflow, and the MonPG blog has more operational deep dives like this one.