pg_upgrade vs a Logical Replication Upgrade Path

A decision guide for choosing between an in-place pg_upgrade and a logical-replication cutover when a major-version upgrade must also carry compiled extensions like PostGIS, TimescaleDB, or pgvector across the boundary.

Up: Async Upgrade Simulation — this page is the route-selection companion to that simulation stage, and it feeds the wider Extension Upgrade Planning & Compatibility Validation pipeline.

Context & When This Applies

When you cross a PostgreSQL major (say 15 → 16) you must choose how the data and its extensions move. Two routes dominate: pg_upgrade, which relinks the existing data files under a new server binary in place, and logical replication, which streams changes from the old cluster to a freshly initialized new one and cuts over when caught up. The choice is rarely about the data alone — it is dominated by the extensions, because pg_upgrade requires binary-compatible extension builds on both clusters, while logical replication requires the publication-compatible subset of your schema. This guide applies to PostgreSQL 12–17 with any compiled extension in play, and it is the decision that precedes every major-version window sized in threshold tuning for downtime windows.

Getting the route wrong is expensive: choosing pg_upgrade when an extension has no matching build on the new major strands the whole cluster at the --check step; choosing logical replication for a schema full of large objects or tables without replica identity strands it mid-sync. Both routes must be proven in an async upgrade simulation on a topology-matched clone first.

Choosing between pg_upgrade and a logical replication cutover A major-version upgrade decision. First gate: does every installed extension have a binary-compatible build on the new major greater than or equal to the current one? If no, pg_upgrade cannot run and logical replication is the route. If yes, second gate: is the tolerated downtime a few minutes or must it be near zero? If a few minutes is acceptable, pg_upgrade with hard links is the simplest, fastest route. If near-zero downtime is required, third gate: does every replicated table have a replica identity and no unsupported objects such as large objects outside the logical decoding scope? If yes, logical replication gives a near-zero cutover. If no, fix replica identity first or fall back to pg_upgrade in a maintenance window. ext builds on new major? near-zero downtime? replica identity + supported? pg_upgrade --link minutes of downtime, simplest Logical replication near-zero cutover yes no → logical no yes

Concept: Two Different Compatibility Contracts

pg_upgrade and logical replication enforce different compatibility contracts, and that is the crux of the decision. pg_upgrade operates at the storage layer: it keeps the on-disk relation files and rewrites only the catalog, so it demands that every extension have a shared library on the new cluster whose version is greater than or equal to the old one — otherwise the first load of, say, postgis-3.so fails. It is fast (with --link, near-instant regardless of data size) but requires the whole cluster offline for the swap, and it carries everything, including objects logical replication cannot.

Logical replication operates at the row layer: it decodes changes from the old cluster’s WAL and replays them as SQL on the new one, so it needs every replicated table to have a replica identity and cannot carry sequences’ current values, large objects, or DDL automatically. It buys a near-zero cutover because the new cluster is built and caught up while the old one still serves traffic, but the schema and extensions must be created on the target up front — exactly the per-extension quirks documented for PostGIS, TimescaleDB, and pgvector. The choice, then, is a trade of downtime against setup complexity and object coverage.

Dimension pg_upgrade Logical replication
Downtime Minutes (with --link) Near-zero cutover
Data-size sensitivity Insensitive with --link Proportional to backlog to catch up
Extension requirement Binary-compatible build ≥ old on new cluster Extensions pre-created on target; ABI independent
Object coverage Everything (LOBs, sequences, DDL) Row data only; sequences/LOBs need manual copy
Replica identity Not required Required on every replicated table
Rollback Restore snapshot of old cluster Keep old cluster running until verified
Best when Simple, short window acceptable Downtime budget is near zero

Runnable Implementation

Before committing to a route, run the preconditions probe below. It answers the two gates that actually decide the route: are the extensions binary-ready for pg_upgrade, and do all tables have a replica identity for logical replication.

#!/usr/bin/env python3
"""Probe both upgrade routes' preconditions and recommend one."""
import psycopg2


def recommend_route(conn, tolerated_downtime_seconds: int) -> dict:
    with conn.cursor() as cur:
        # Extensions installed here — each needs a >= build on the new major
        # for pg_upgrade. (Availability on the target is checked out of band.)
        cur.execute("SELECT extname, extversion FROM pg_extension "
                    "WHERE extname NOT IN ('plpgsql') ORDER BY extname;")
        extensions = cur.fetchall()

        # Tables lacking a replica identity block logical replication of updates.
        cur.execute(
            """
            SELECT n.nspname, c.relname
            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  c.relreplident = 'n'          -- 'n' = nothing (no identity)
            ORDER  BY 1, 2;
            """
        )
        no_identity = cur.fetchall()

    if tolerated_downtime_seconds >= 300 and not no_identity:
        route = "pg_upgrade"          # short window fine, simplest path
    elif no_identity:
        route = "pg_upgrade"          # logical blocked until identities added
    else:
        route = "logical_replication" # near-zero downtime and tables are ready

    return {
        "recommended": route,
        "extensions": [f"{n} {v}" for n, v in extensions],
        "tables_without_replica_identity": [f"{s}.{t}" for s, t in no_identity],
        "note": "Confirm each extension has a >= build on the target before "
                "pg_upgrade; add REPLICA IDENTITY before logical replication.",
    }


if __name__ == "__main__":
    conn = psycopg2.connect("dbname=appdb")
    import json
    print(json.dumps(recommend_route(conn, tolerated_downtime_seconds=120), indent=2))

Whichever route the probe recommends, drive its execution through ALTER EXTENSION automation and keep a pre-cutover restore point via snapshot and point-in-time recovery.

Expected Output & Verification

The probe returns a route plus the blockers it found:

{
  "recommended": "pg_upgrade",
  "extensions": ["pg_stat_statements 1.10", "postgis 3.4.2"],
  "tables_without_replica_identity": ["public.audit_log"],
  "note": "Confirm each extension has a >= build on the target before pg_upgrade; add REPLICA IDENTITY before logical replication."
}

For a pg_upgrade route, the authoritative verification is a dry --check run on the target binaries:

# Non-destructive: validates catalog and extension compatibility only.
/usr/lib/postgresql/16/bin/pg_upgrade \
  --old-datadir /var/lib/postgresql/15/main \
  --new-datadir /var/lib/postgresql/16/main \
  --old-bindir  /usr/lib/postgresql/15/bin \
  --new-bindir  /usr/lib/postgresql/16/bin \
  --check

A clean *Clusters are compatible* is the go signal. Feed the result into compatibility matrix synchronization so the approved route and extension tuple are recorded together.

Edge Cases & Gotchas

pg_upgrade --check fails on a missing extension library. If the new cluster lacks a matching build:

Your installation contains extension "postgis" which is not present in the new
cluster. Install the missing extension before continuing.

Install the target-major extension package on the new cluster before re-running --check. This is the single most common pg_upgrade blocker for extension-heavy fleets.

Logical replication silently skips tables without a replica identity. Updates and deletes on such a table error on the subscriber or are dropped, depending on configuration. Add REPLICA IDENTITY FULL (or a suitable key) to every replicated table before starting, and verify with the probe’s relreplident check.

Sequences do not advance on the logical target. Logical replication copies row data but not sequence state, so the new cluster’s sequences sit at their initial value. Copy last_value for every sequence during cutover or the first insert collides with existing IDs.

TimescaleDB and other preload extensions constrain both routes. A preload extension must be in shared_preload_libraries on the target before either route starts, and some versions restrict pg_upgrade across specific majors — reconcile with TimescaleDB extension upgrade quirks and preload ordering before choosing.