Logical vs physical replication differences

Physical replication ships opaque, block-level Write-Ahead Log (WAL) to a byte-for-byte standby, while logical replication decodes that same WAL into…

Physical replication ships opaque, block-level Write-Ahead Log (WAL) to a byte-for-byte standby, while logical replication decodes that same WAL into row-level change events that a consumer can filter, transform, and route — this guide maps the two models parameter by parameter as a companion to the publication/subscription model on PostgreSQL 14 through 17. Choosing wrong is a production-grade mistake: pick physical for a change-data-capture (CDC) sink and you inherit strict version parity with zero row filtering, while picking logical for a hot-standby failover target saddles you with decoding CPU, slot-retention risk, and asynchronous, at-least-once delivery.

Physical versus logical replication of the same WAL Both models read the same primary WAL. Physical replication ships opaque binary WAL blocks verbatim to a single byte-for-byte standby that must run an identical major version and collation and replays in strict WAL order. Logical replication runs the pgoutput plugin to decode the WAL into row-level INSERT, UPDATE and DELETE events that can be filtered, routed and transformed, then fanned out at-least-once to many independent targets such as a subscriber, a warehouse or a Kafka sink, each confirming its flushed LSN. Physical replication Logical replication Primary WAL Binary WAL blocks block-level · opaque Byte-for-byte standby identical version + collation 1:1 · strict WAL replay order restart_lsn tracks replay ship verbatim Primary WAL Logical decoding pgoutput → row images INSERT UPDATE DELETE filter · route · transform Subscriber Warehouse Kafka sink at-least-once · confirmed_flush_lsn

Commit & Behavior Semantics

Both models read the same physical WAL, but they diverge the moment a record leaves the walsender. Physical replication requires only wal_level = replica (the default since PostgreSQL 10) and streams binary WAL segments directly to standbys. It bypasses SQL parsing entirely, so serialization overhead on the primary is near zero — the trade is strict schema parity, identical major versions, and identical collation between primary and standby. Logical replication mandates wal_level = logical, which instructs the logical decoding subsystem to log the extra relation and old-tuple metadata needed to reconstruct row images. A background walsender then runs the pgoutput plugin over the reorder buffer and emits structured messages — BEGIN, INSERT, UPDATE, DELETE, TRUNCATE, COMMIT — the same shape a downstream parser reads when parsing the pgoutput format with psycopg2. The mechanics of that decode step are covered in depth under WAL stream mechanics.

The durability and latency consequences of that split are exact, not qualitative:

Dimension Physical replication Logical replication
WAL level replica (default) logical (restart required)
Transport unit Binary WAL block Row-level change event via pgoutput
Durability marker restart_lsn tracks WAL replay on the standby confirmed_flush_lsn tracks apply confirmation from the subscriber
Commit ordering Strict WAL apply order on standby Per-transaction commit order; large txns may stream (streaming = on, PG 14+)
Delivery guarantee Exact byte mirror At-least-once — consumers must be idempotent
Schema requirement Identical major version, collation, layout Cross-version, selective table/column routing
Topology 1:1 or cascading standbys Decoupled publisher → many subscribers
Primary overhead Minimal (no decode) Moderate — decode CPU + reorder-buffer memory
Sync commit synchronous_standby_names gives zero-data-loss Async by default; sync apply is coarse and rarely used
CDC suitability Low (requires external WAL parsing) High (native, schema-annotated change events)

The single most consequential row is the durability marker. On a physical slot, restart_lsn advances as the standby replays segments; on a logical slot, WAL is pinned until the subscriber returns a confirmed_flush_lsn, and a stalled consumer therefore holds WAL on the primary indefinitely. That retention risk is the price of the routing flexibility in the right-hand column.

Diagnostic Patterns

Distinguishing the two models on a running cluster — and catching the failure mode unique to logical slots — comes down to three catalog views. Start by confirming which decode mode is actually live, not just staged:

sql
-- logical decoding is only active when the RUNNING server reports 'logical'.
SELECT name, setting, pending_restart
FROM pg_settings
WHERE name IN ('wal_level', 'max_wal_senders', 'max_replication_slots');
-- pending_restart = true means the change is staged but NOT yet in effect.

Inspect every slot’s type and its retained WAL. On a logical slot, restart_lsn lagging far behind the write head is the leading indicator of unbounded pg_wal growth:

sql
-- Per-slot retention. Alert when retained_bytes crosses ~50% of max_slot_wal_keep_size,
-- and page immediately if it exceeds it (PG 13+ will mark the slot 'lost' and break it).
SELECT slot_name,
       slot_type,                                   -- 'physical' vs 'logical'
       active,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
       ) AS retained_bytes,
       pg_size_pretty(
         pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)
       ) AS unconfirmed_bytes,                       -- NULL for physical slots
       wal_status                                    -- reserved | extended | unreserved | lost
FROM pg_replication_slots
ORDER BY slot_type, retained_bytes DESC;

Two thresholds matter operationally. Treat active = false on a logical slot for longer than 300 s as an incident — an idle consumer is silently accumulating WAL. Treat wal_status = 'lost' as terminal: the segments the slot needed have been recycled and the only recovery is to drop the slot and reseed downstream. For the acknowledgement side, compare send and apply positions on the live stream:

sql
-- Live lag per connected consumer. write/flush/replay lag are intervals (PG 10+).
SELECT application_name,
       client_addr,
       state,                                        -- streaming | catchup | backup
       pg_size_pretty(
         pg_wal_lsn_diff(sent_lsn, replay_lsn)
       ) AS apply_lag_bytes,
       replay_lag
FROM pg_stat_replication;

For sizing max_replication_slots before you provision consumers, the safe-headroom math is worked through under configuring max_replication_slots safely.

Safe Deployment Sequence

Moving a physical-only primary to also feed a logical CDC stream is a zero-downtime change with one hard constraint: raising wal_level from replica to logical requires a full restart and cannot be hot-reloaded. Sequence it so the restart is the only disruptive step and every prior step is reversible.

  1. Stage the reload-safe GUCs first. These take effect immediately and do not disturb existing physical standbys.

    sql
    ALTER SYSTEM SET max_wal_senders = 10;         -- one per stream + failover headroom
    ALTER SYSTEM SET max_replication_slots = 10;
    ALTER SYSTEM SET logical_decoding_work_mem = '128MB';
    ALTER SYSTEM SET max_slot_wal_keep_size = '40GB';  -- PG 13+: hard cap on stalled-slot WAL
    SELECT pg_reload_conf();
    
  2. Stage wal_level and confirm it is pending, not live. Nothing changes until the bounce.

    sql
    ALTER SYSTEM SET wal_level = 'logical';
    SELECT setting, pending_restart FROM pg_settings WHERE name = 'wal_level';
    -- expect: setting='replica', pending_restart='t'
    
  3. Restart during a maintenance window. Physical standbys reconnect automatically; budget the extra 10-30% WAL volume that logical produces on update-heavy workloads. Verify SHOW wal_level; returns logical on the running primary before proceeding.

  4. Create the slot and publication without a consumer yet. Pre-creating the slot lets you validate replica identity before any WAL is pinned in earnest.

    sql
    SELECT pg_create_logical_replication_slot('analytics_cdc', 'pgoutput');
    CREATE PUBLICATION analytics_cdc_pub FOR TABLE orders, customers;
    
  5. Attach the subscriber and complete the initial subscription sync. Only now does the slot begin advancing confirmed_flush_lsn.

Revert procedure. To back the change out cleanly, drop the consumer-facing objects before lowering wal_level, so no orphaned slot pins WAL through the restart:

sql
DROP SUBSCRIPTION analytics_cdc_sub;               -- on the subscriber
DROP PUBLICATION analytics_cdc_pub;                -- on the publisher
SELECT pg_drop_replication_slot('analytics_cdc');  -- releases pinned WAL immediately
ALTER SYSTEM SET wal_level = 'replica';            -- takes effect on the next restart

If you lower wal_level while a logical slot still exists, the slot becomes unusable after the restart and continues to pin WAL until dropped — always release slots first.

Pipeline Integration

A physical standby needs no application code; a logical consumer is an application, and the semantics table above dictates its shape. Because logical delivery is at-least-once, every sink write must be idempotent. Key downstream rows on the source primary key and upsert:

python
# Idempotent apply. A redelivered change after a reconnect is a no-op, not a duplicate.
UPSERT = """
    INSERT INTO warehouse.orders (id, status, total, src_lsn)
    VALUES (%(id)s, %(status)s, %(total)s, %(lsn)s)
    ON CONFLICT (id) DO UPDATE
       SET status = EXCLUDED.status,
           total  = EXCLUDED.total,
           src_lsn = EXCLUDED.src_lsn
     WHERE warehouse.orders.src_lsn < EXCLUDED.src_lsn;  -- ignore stale replays
"""

Advance confirmed_flush_lsn only after the sink write is durably committed, and send a periodic heartbeat during idle windows so the slot’s restart_lsn keeps moving even when the source is quiet — otherwise an idle publisher plus a well-behaved consumer can still accumulate WAL:

python
import time

def stream(cur, apply_change, feedback):
    last_status = time.monotonic()
    for msg in cur:                         # psycopg2 logical replication cursor
        apply_change(msg)                   # durable sink write (upsert above)
        # Only confirm what is safely persisted downstream.
        msg.cursor.send_feedback(flush_lsn=msg.data_start)
        # Heartbeat at least every 10 s so restart_lsn advances while idle.
        if time.monotonic() - last_status > 10:
            msg.cursor.send_feedback(reply=True)
            last_status = time.monotonic()

Wrap reconnects in bounded exponential backoff (for example 1 s doubling to a 60 s ceiling) and resume from the last confirmed LSN — the slot remembers the position, so a restarted consumer replays only unconfirmed changes. Export retained_bytes, apply_lag_bytes, and slot active state from the diagnostic queries above to your metrics backend so retention breaches alert before they page. For a managed connector that implements this loop, snapshotting, and schema handling out of the box, see Debezium connector configuration.

Failover handling. This is where logical diverges most sharply from physical. A physical standby promotes with pg_ctl promote and needs no slot migration. A logical slot does not follow a promotion (before PG 16’s failover slots), so after the primary is lost you must recreate the slot on the new primary and skip already-applied transactions to prevent duplicate delivery:

sql
-- On the promoted standby, after recreating the slot:
SELECT pg_replication_origin_advance('pg_analytics_cdc_sub', '0/1A2B3C4D');
-- advance the origin to the last LSN the sink durably applied, then resume.

If slot state cannot be recovered, fall back to a bounded snapshot — COPY (SELECT ... WHERE updated_at > :last_sync) TO STDOUT — to bridge the gap, then re-attach the stream. Role scoping, hostssl rules, and the REPLICATION privilege these consumers connect with are detailed under security boundaries & permissions.

Authoritative references