Using Logical Replication for Blue-Green Database Cutover

The team needs to move the production PostgreSQL database to a new cluster — new hardware, a new major version, a different storage layout — and the business will accept a pause measured in seconds, not the hours a dump and restore would take. PostgreSQL’s built-in logical replication makes that possible: the new (“green”) database subscribes to the old (“blue”) one, copies the existing data, streams every subsequent change, and at a chosen moment the application’s connections move from blue to green after a brief write pause. The mechanism is simple; the details — tables without primary keys, sequences, DDL during the window, how exactly to pause writes and repoint connections — are where cut-overs go wrong. This guide works through them in order. It is the core procedure of Blue-Green Deployments for Databases.

The Cut-Over Sequence Sequence between the operator, PgBouncer, blue, green and the application. The operator pauses PgBouncer so new queries wait; checks that green has replayed all of blue's WAL; syncs sequence values to green; enables reverse replication; points PgBouncer at green and resumes. Application queries that waited during the pause continue against green. The Cut-Over Sequence Operator PgBouncer Blue Green PAUSE app (drain in-flight) lag to green = 0? setval() for every sequence ENABLE reverse subscription point app at green; RESUME queued queries continue
The application only sees queries waiting a few seconds at the pooler; it never connects to a half-synchronised database.

Symptom / Error Signatures

The failures that derail logical-replication cut-overs are well known:

ERROR:  cannot update table "audit_events" because it does not have a replica identity and publishes updates
ERROR:  logical replication target relation "public.orders" is missing replicated column: "region"
ERROR:  duplicate key value violates unique constraint "orders_pkey"          -- on green, after cut-over
ERROR:  could not create replication slot "bluegreen_sub": ERROR: all replication slots are in use

Operationally: WAL piling up on blue because the subscription stalled (pg_replication_slots shows a growing retained size), a cut-over pause that stretches to minutes because lag was not near zero, and applications that keep connections to blue because they bypass the pooler.

Root Cause Analysis

Logical replication decodes blue’s WAL into row changes for the tables in a publication and applies them on green through a subscription. Its guarantees and gaps determine the procedure:

Property Behaviour Consequence
Initial data copy_data = true copies existing rows, then streams green converges without a dump/restore
Row identification updates/deletes matched by replica identity (primary key) tables without a PK need one, or REPLICA IDENTITY FULL
DDL not replicated freeze schema changes or apply to green first
Sequences values not replicated set on green during the pause
Large objects not replicated migrate separately or avoid
Version green may be newer than blue enables major-version upgrades

The cut-over is a consistency problem: at the moment connections move, green must contain every change committed on blue, and nothing may write to blue afterwards. The cleanest way to guarantee that is a pooler-level pause (PgBouncer PAUSE waits for in-flight transactions to finish and holds new ones), followed by confirming green has replayed blue’s final WAL position, then switching the pooler’s target.

Components of the Cut-Over Applications connect only through PgBouncer. PgBouncer points at blue before the cut-over and at green after. Blue publishes all tables; green subscribes. A reverse publication on green and a disabled subscription on blue are prepared for rollback. A checker compares row counts and checksums between the two. Components of the Cut-Over Applications connect via pooler only PgBouncer PAUSE → retarget → RESUME Blue (PG 14) publication bluegreen Green (PG 17) subscription + reverse pub Checker counts + checksums before logical replication
Routing every connection through the pooler is what makes the switch a single, controllable step.

Immediate Mitigation

If a cut-over attempt is going wrong:

1. Abort cleanly while blue is still authoritative. If writes are paused and something fails before the switch, resume the pooler against blue; nothing has been lost, and the subscription keeps green in sync for the next attempt.

# Shell · PgBouncer admin console · resume against blue if the cut-over is aborted
psql -h pgbouncer -p 6432 -U pgbouncer pgbouncer -c "RESUME app;"

2. Fix replica identity problems for tables without primary keys before retrying.

-- PostgreSQL · on blue · tables in the publication without a primary key
SELECT c.oid::regclass FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = c.oid AND k.contype = 'p');
-- then, per table (slower updates, but correct):
ALTER TABLE audit_events REPLICA IDENTITY FULL;

3. If green shows duplicate-key errors after cut-over, sequences were not synced. Set them immediately (next step’s script) — the pause does not need to be repeated.

Permanent Fix / Long-Term Pattern

1. Prepare green and replication well before the cut-over. Restore the schema on green, create the publication and subscription, let the initial copy finish, and watch lag stabilise near zero under normal load. Freeze schema migrations for the window, or apply additive DDL to green before blue.

2. Verify equivalence. Compare row counts for every table and checksums for important tables in id ranges on both sides; investigate any difference.

3. Rehearse the cut-over on staging, timing each step.

4. Execute the cut-over as a script.

# Shell · operator host · PgBouncer admin + psql to blue and green
# WARNING: run only after lag is near zero; the PAUSE blocks application queries until RESUME.
set -euo pipefail
psql "$PGB_ADMIN" -c "PAUSE app;"
BLUE_LSN=$(psql -At "$BLUE_URL" -c "SELECT pg_current_wal_lsn()")
until [ "$(psql -At "$BLUE_URL" -c "SELECT bool_and(replay_lsn >= '$BLUE_LSN') FROM pg_stat_replication WHERE application_name = 'bluegreen_sub'")" = "t" ]; do sleep 0.2; done
psql -At "$BLUE_URL" -c "SELECT format('SELECT setval(%L, %s, true);', schemaname||'.'||sequencename, coalesce(last_value, 1)) FROM pg_sequences" \
  | psql "$GREEN_URL"
psql "$BLUE_URL" -c "ALTER SUBSCRIPTION reverse_sub ENABLE;"
ln -sfn /etc/pgbouncer/pgbouncer-green.ini /etc/pgbouncer/pgbouncer.ini   # [databases] now points at green
psql "$PGB_ADMIN" -c "RELOAD;"
psql "$PGB_ADMIN" -c "RESUME app;"
# ROLLBACK PATH: PAUSE, wait for green→blue lag 0, sync sequences to blue, point PgBouncer at blue, RESUME.

The exact mechanism for retargeting the pooler varies by deployment (a config reload, a DNS change, a service discovery update); what matters is that it happens while the pause is in effect. Some teams disable the forward subscription on green after the switch so nothing from blue can arrive late.

5. Soak with reverse replication running, then retire blue as described in rolling back a blue-green database cutover. Monitor slot lag throughout, per using change data capture instead of application dual-writes.

Where the Write Pause Goes Stacked bar of seconds in a rehearsed cut-over pause. Waiting for in-flight transactions to drain: 3 seconds. Waiting for green to replay the final LSN: 1 second. Syncing sequences: 2 seconds. Enabling reverse replication and retargeting the pooler: 2 seconds. Total about 8 seconds. Where the Write Pause Goes rehearsed cut-over 3 s 1 s 2 s 2 s drain in-flight final replay sequences retarget + resume
A rehearsed script keeps the pause to seconds; long transactions at pause time are the main thing that stretches it.

Verification Checklist

Frequently Asked Questions

Can green run a newer PostgreSQL major version than blue? Yes. Logical replication works between major versions (from PostgreSQL 10 onwards), which is why it is the standard approach for low-downtime major upgrades.

Why are sequences a problem? Logical replication copies row changes but not sequence state, so green’s sequences stay at their initial values. Without syncing them during the pause, the first inserts on green collide with replicated rows.

How long does the write pause last? Typically a few seconds in a rehearsed cut-over: draining in-flight transactions, waiting for final replay, syncing sequences, and retargeting connections. Long-running transactions at pause time are the usual reason it takes longer.

What if an application connects directly to blue, bypassing the pooler? It will keep writing to blue after the switch, and those writes will not reach green (unless reverse replication carries them, which then conflicts with green’s own writes). Inventory every connection path before the cut-over.

Do I need to stop DDL during the window? Yes, or coordinate it: logical replication does not replicate schema changes, and a column added on blue but not on green breaks the subscription.