Origin filtering is the PostgreSQL 16 subscription option that stops a two-way replication set from endlessly re-applying its own changes. It is the single mechanism that makes native active-active replication viable, and it is the first thing to get right in the bidirectional replication and conflict resolution design — because without it, a change written on one node is applied on the other, re-decoded from that node’s WAL, and sent straight back, saturating both apply workers and inflating WAL forever.
This page covers the origin filter specifically: what CREATE SUBSCRIPTION ... WITH (origin = none) requests versus the default origin = any, how replication origins stamp provenance onto every applied change, how to read that provenance through pg_replication_origin and pg_replication_origin_status, and the exact failure you get if you omit the filter. All behavior is validated against PostgreSQL 16 and 17; the option does not exist before PG 16, which shapes the workarounds at the end.
origin = any a change echoes between the nodes without bound; with origin = none the subscription asks only for changes that originated on the partner, so the echo is filtered out at the source.Origin Filter Semantics
Every change PostgreSQL applies through a subscription is stamped with a replication origin — a small integer identity recorded alongside the apply so the server knows the change came from replication rather than a local client. The origin subscription option decides whether that provenance stamp is used to filter what the publisher sends. The table maps each value to what the subscription requests and what it means in a two-way set.
origin value |
PG version | What the subscription requests | Two-way outcome |
|---|---|---|---|
any (default) |
16+ (and pre-16 behavior) | Every change, regardless of where it originated | Echoes are re-sent and re-applied without bound; never use on both sides of a two-way set |
none |
16+ | Only changes with no replication origin — i.e. writes made locally on the partner, not forwarded from a third node | Locally-originated writes flow once; the echo carries an origin and is filtered out at the publisher |
The distinction is provenance, not content. A change a client commits directly on node B has no replication origin, so it is “origin none” from B’s perspective and a subscription with origin = none requests it. A change that arrived on B by replication from A is stamped with A’s origin; requesting origin = none excludes it, which is exactly what stops the loop. The filter is evaluated on the publisher during decoding, so the echoed change is never even transmitted — it costs no apply work and no network, unlike a scheme that ships everything and discards duplicates downstream.
Two boundaries are worth internalizing. First, origin = none is not transitive across three or more nodes: in a mesh of three, a change that reaches node C via B carries B’s origin, and a none filter on C’s subscription to B would drop it — which is why native origin filtering cleanly supports a two-node pair but a full mesh needs the explicit conflict-and-routing machinery of pglogical/BDR rather than stock subscriptions. Second, the filter changes only which changes are sent; it does nothing about conflicts between two genuinely concurrent local writes, which still require the resolution rules covered separately. The provenance model itself is part of the broader publication and subscription model.
Diagnostic Patterns
Verify that origins exist and are advancing, because a missing or stuck origin is how a “working” two-way set silently starts looping or stalls. Each active subscription registers exactly one origin; pg_replication_origin lists them and pg_replication_origin_status shows how far each has been applied.
-- One origin per subscription. If a subscription has no origin row,
-- its provenance is not being tracked and origin filtering cannot work.
SELECT roname, roident FROM pg_replication_origin ORDER BY roident;
-- Live apply position per origin: local_lsn is where we've applied to,
-- remote_lsn is the partner position it corresponds to.
-- ALERT: remote_lsn frozen while the partner keeps writing = apply stalled.
SELECT external_id, local_id, remote_lsn, local_lsn
FROM pg_replication_origin_status;
Confirm the filter is actually set the way you think — a subscription silently created with the default any is the classic cause of a loop that appears only under two-way load:
-- Inspect the origin filter on every subscription. Expect 'none' on both
-- sides of a two-way set. 'any' here on a bidirectional pair is the bug.
SELECT subname, subenabled, suborigin -- suborigin: PG 16+
FROM pg_subscription
ORDER BY subname;
The behavioral signature of a missing filter is unmistakable once you know it: application write rate is flat, yet WAL generation and both apply workers’ CPU climb together, and pg_stat_replication on each node shows a busy sender with nothing new actually landing in the tables. Cross-check by watching whether row counts change while the workers churn — if apply is busy but no logical state advances, changes are echoing.
-- Are the senders busy shipping changes that never change table state?
SELECT application_name, state,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS in_flight_bytes
FROM pg_stat_replication;
Safe Deployment Sequence
Set the origin filter at subscription creation, before any two-way traffic flows. Retrofitting it onto a running loop means first stopping the storm, which is more disruptive than getting it right up front.
1. Create both subscriptions with origin = none. The filter must be present on both directions; setting it on only one side still lets that side’s echo loop back through the unfiltered subscription.
-- On Node B, subscribing to A. Mirror this on Node A subscribing to B.
CREATE SUBSCRIPTION sub_from_a
CONNECTION 'host=node-a dbname=app application_name=sub_from_a sslmode=verify-full'
PUBLICATION bidi_app
WITH (origin = none, copy_data = false);
2. Fix an existing subscription in place if you must retrofit. origin is alterable, but the change takes effect only after the apply worker restarts, so disable, alter, and re-enable to force it.
-- Convert a mistakenly-any subscription to none without recreating it.
ALTER SUBSCRIPTION sub_from_a DISABLE;
ALTER SUBSCRIPTION sub_from_a SET (origin = none);
ALTER SUBSCRIPTION sub_from_a ENABLE; -- restart picks up the new filter
3. Stop a live loop before it fills the disk. If a loop is already running, disable both subscriptions immediately to halt the echo, then set origin = none on each and re-enable. Check slot retention on both nodes first, because the loop may already be pinning WAL.
-- Emergency: quiesce both directions, then reconfigure and resume.
ALTER SUBSCRIPTION sub_from_a DISABLE;
ALTER SUBSCRIPTION sub_from_b DISABLE; -- run the matching command on the other node
4. Confirm the loop is gone. After re-enabling, verify apply-side CPU falls back to baseline and pg_replication_origin_status.remote_lsn advances only when the partner has genuinely new local writes, not on a fixed churn.
Pipeline Integration
A CDC consumer reading one node of a two-way pair faces the same provenance problem the origin filter solves for the database: without awareness of origin, it emits every change twice — once as the local write, once as the replicated echo. When the source is PostgreSQL 16+ you can push the filter down so the consumer’s slot never receives echoes, keeping downstream dedup unnecessary.
# When consuming logical changes directly, request only locally-originated
# changes so a two-way partner's echo never reaches the pipeline.
# pgoutput exposes origin metadata per transaction; skip foreign-origin ones.
def keep_change(txn) -> bool:
# A transaction replicated in from the partner carries a non-empty origin.
return txn.origin in (None, "", "pg_local") # emit only local-origin work
for txn in stream:
if not keep_change(txn):
continue # drop the echo, do not double-emit
emit(txn)
For failover, remember that a rebuilt or promoted node loses its replication origins along with its slots, so after promotion you must confirm each surviving subscription still has an origin and that the reconnecting side is created with origin = none again — an origin that comes back as any reopens the loop. The native reconnect-and-reseed path is documented in subscription sync procedures, and the resolution logic that handles the concurrent writes the filter cannot prevent is in resolving conflicts in bidirectional replication.
Authoritative references
- PostgreSQL:
CREATE SUBSCRIPTION— theoriginoption (none/any), added in PostgreSQL 16. - PostgreSQL: Replication Progress Tracking — how replication origins stamp and track provenance.
- PostgreSQL:
pg_replication_origin_status— the view exposing per-origin apply positions.
Related
- Resolving conflicts in bidirectional replication — handling the concurrent writes that origin filtering does not eliminate.
- Subscription sync procedures — recreating subscriptions and origins after failover or reseed.
- Bidirectional Replication & Conflict Resolution — the parent guide covering the full two-way topology this filter protects.