PostgreSQL HA: Active/Active Replication Explained

postgresql active/active, postgres active active, postgresql ha

PostgreSQL has no built-in, supported active/active (multi-master) replication. As of PostgreSQL 17.10 in August 2026, your three real options are: buy EDB Postgres Distributed; build a restricted bidirectional setup yourself using logical replication with origin = none (PostgreSQL 16+), accepting that conflicts are detected but never resolved for you; or avoid multi-master entirely with streaming replication plus a failover manager. This page shows the middle option working on a real cluster, then shows it breaking.

This page previously published a procedure that cannot work

An earlier version of this article gave a seven-step setup built on the BDR plugin, naming bdr.bdr_group_create(), bdr.bdr_group_join(), bdr.bdr_nodes and bdr.bdr_part_by_node_names(). That procedure targeted BDR 1.x, which required a patched fork of PostgreSQL 9.4 — a version that reached end of life on 2020-02-13. None of it applies to any supported PostgreSQL release, and following it on a modern server will fail at the first step.

The state of that project is not ambiguous. The GitHub repository 2ndQuadrant/bdr has a default branch literally named deprecated, its last push was 2020-02-20, and its own description reads “deprecated, please visit 2ndQuadrant website for latest BDR3”. The old documentation host bdr-project.org no longer resolves. 2ndQuadrant was acquired by EDB in 2020, and the successor product is EDB Postgres Distributed (PGD), which EDB describes as “powered by the latest generation of BDR”.

The old page also said: “Configure track_commit_timestamp with the bdr.bdr_part_by_node_names parameter.” That sentence was meaningless. track_commit_timestamp is a server setting in postgresql.conf; bdr_part_by_node_names was a function for removing a node from a group. It has been deleted rather than repaired.

On a stock PostgreSQL 17.10 server, every one of those objects is absent — no bdr schema, no bdr extension available to install, and no function by any of those names.

What multi-master actually costs

The hard part of active/active is not replication. It is that two nodes can accept contradictory writes at the same moment, and no protocol can undo that afterwards — it can only pick a winner. Any system offering “last update wins” is telling you it will silently discard somebody’s committed data. That is a business decision, not a configuration setting.

You avoid the problem far more often than you solve it: partition writes so each row has exactly one node allowed to write it, or use a single writer with read replicas. Multi-master earns its cost mainly when writes must survive losing a whole region.

Bidirectional logical replication on stock PostgreSQL 16+

PostgreSQL 16 added the subscription parameter that makes this possible. The release notes describe it as: “Allow logical replication subscribers to process only changes that have no origin … This can be used to avoid replication loops.” Without it, two nodes subscribed to each other would echo every change back and forth forever.

Set wal_level = logical on both nodes, create a matching table and publication on each, then subscribe each node to the other:

-- on node A
CREATE SUBSCRIPTION s_from_b
  CONNECTION 'host=nodeB port=5432 user=postgres dbname=postgres'
  PUBLICATION p WITH (origin = none, copy_data = false);

-- on node B
CREATE SUBSCRIPTION s_from_a
  CONNECTION 'host=nodeA port=5432 user=postgres dbname=postgres'
  PUBLICATION p WITH (origin = none, copy_data = false);

Note copy_data = false. The documentation warns that with copy_data = true and origin = NONE, “the initial sync table data is copied directly from the publisher, meaning that knowledge of the true origin of that data is not possible” — so seed the nodes yourself before wiring them together.

Tested on two PostgreSQL 17.10 clusters, this works. Inserting a different row on each node left both nodes holding both rows, and the row count stayed at 2 rather than growing, confirming no echo loop.

Now the part that matters

The same two nodes were given the same primary key at the same time — id = 99, written as “A version” on A and “B version” on B. The result:

node A: 99 | A version
node B: 99 | B version

ERROR:  duplicate key value violates unique constraint "t_pkey"
DETAIL:  Key (id)=(99) already exists.
CONTEXT:  processing remote data for replication origin "pg_16406" during
          message type "INSERT" for replication target relation "public.t"
LOG:  background worker "logical replication apply worker" exited with exit code 1

The nodes now permanently disagree, and nothing resolves it. Worse, replication is stopped, not degraded: a clean, unrelated row inserted on A afterwards never arrived on B at all, while apply_error_count climbed as the apply worker restarted and failed every few seconds. The documentation is explicit: “A conflict that produces an error will stop the replication; it must be resolved manually by the user.”

That is the honest shape of do-it-yourself multi-master on core PostgreSQL: it replicates correctly right up until it doesn’t, and then it needs a human. If you build on it, monitor pg_stat_subscription_stats, have a plan for divergence, and keep primary keys node-scoped so collisions cannot happen in the first place.

PostgreSQL 18, and what is still missing

PostgreSQL 18 improved conflict visibility. It names conflict types — insert_exists, update_origin_differs, update_exists, update_missing, delete_origin_differs, delete_missing, multiple_unique_conflicts — logs them, and counts them in pg_stat_subscription_stats. Detecting that a row “was previously modified by another origin” requires track_commit_timestamp enabled on the subscriber.

It is still detection, not resolution. On update_origin_differs the docs say “Currently, the update is always applied regardless of the origin of the local row.” PostgreSQL 18 will tell you that you have diverged; it will not decide who was right.

What was and was not verified here

Verified by execution on PostgreSQL 17.10 (macOS 26.6, arm64, 2026-08-09): the absence of all BDR objects; origin = none being accepted and stored; convergence without an echo loop; permanent divergence and stalled replication on a same-key conflict; and the absence of confl_* columns on a pre-18 server.

Not verified here, and stated on the strength of documentation only: the PostgreSQL 18 conflict types and statistics columns, since the machine used runs 17.10. EDB Postgres Distributed was not tested at all — it is a commercial product with no public source, so nothing on this page should be read as an assessment of how it performs or how it resolves conflicts. The BDR repository and domain status were checked directly against GitHub’s API and DNS on 2026-08-09.

Reproducing this

The script verify-active-active-replication-guide.sh creates two temporary PostgreSQL clusters, wires up bidirectional replication, reproduces both the working case and the conflict, prints PASS or FAIL for each claim, and deletes both clusters on exit. On PostgreSQL 17.10 it reports 24 passed, 0 failed. It needs PostgreSQL 16 or later and touches no existing database.

Check this yourself. Every command and every block of output on this page is reproduced by /verify/active-active-replication-guide.sh. Download it and run it: it creates its own scratch files, prints one line per claim, cleans up after itself, and exits non-zero if any claim here turns out to be wrong. If it disagrees with this page, the page is wrong.

Photo of author
Sudhir P. founded HeatWare.com in 1999 and has built and operated it full-stack ever since; it is now used by more than 88,000 people. He writes here about the PostgreSQL, MySQL, Linux and DevOps work that keeps it running. Articles are rewritten only after the commands in them have actually been run, and the verification scripts are published alongside them so anyone can check the claims. Reach him at blog@heatware.net.