Physical vs. Logical Upgrade Mechanics
PostgreSQL major upgrades (such as migrating from version 15 to 16 or 16 to 17) change the layout of the system catalog tables. Because of these catalog changes, you cannot perform minor upgrades by simply swapping binaries; major upgrades require structural catalog transformations.
To upgrade a cluster, database administrators generally choose between two patterns:
- Logical Replication Upgrades: This approach involves deploying a target cluster running the new major version of PostgreSQL. Data is replicated from the old source cluster to the new target cluster using logical replication. When data synchronization is complete, client traffic is redirected to the new cluster. This pattern reduces write downtime to the time required to update connection strings. However, it requires significant manual setup, including copying schema definitions and handling large transactional workloads during replication.
- Physical Upgrades via
pg_upgradein Link Mode: This method upgrades the system catalog files while reusing the existing physical table storage files (heaps and indexes). By runningpg_upgradewith the--linkflag, the utility creates hard links from the new cluster’s data directories directly to the physical files of the old cluster. Link mode runs in seconds regardless of database size, since it avoids physical data copying. However, it is irreversible. Once the new cluster starts writing to the physical files, the old cluster’s data directory is modified and cannot be restored.
Automation Scripting for pg_upgrade --link
The shell script below automates a major version upgrade using pg_upgrade --link. It handles compatibility checks, captures replication slot configurations, stops database servers, runs the upgrade utility, and updates statistics on the new cluster.
#!/usr/bin/env bash
set -euo pipefail
# Configuration variables
OLD_BIN="/usr/lib/postgresql/15/bin"
NEW_BIN="/usr/lib/postgresql/16/bin"
OLD_DATA="/var/lib/postgresql/15/main"
NEW_DATA="/var/lib/postgresql/16/main"
PG_PORT="5432"
TEMP_PORT="5433"
log() {
echo -e "\033[1;32m[$(date +'%Y-%m-%dT%H:%M:%S%z')]\033[0m $1"
}
log "Step 1: Run pre-upgrade compatibility checks"
# The -c flag checks for structural compatibility without modifying data files
$NEW_BIN/pg_upgrade \
--old-datadir="$OLD_DATA" \
--new-datadir="$NEW_DATA" \
--old-bindir="$OLD_BIN" \
--new-bindir="$NEW_BIN" \
--check
log "Step 2: Capture active replication slot metadata"
# Logical and physical replication slots are not automatically migrated by pg_upgrade.
# We extract this metadata to recreate slots on the new node post-upgrade.
psql -p "$PG_PORT" -d postgres -t -A -c \
"SELECT slot_name, plugin, slot_type FROM pg_replication_slots WHERE active = false;" > /tmp/replication_slots.txt || true
log "Step 3: Stop cluster instances gracefully"
# Clusters must be stopped cleanly. Running pg_upgrade on running databases will cause errors.
pg_ctl -D "$OLD_DATA" stop -m fast
pg_ctl -D "$NEW_DATA" stop -m fast
log "Step 4: Execute link-mode upgrade"
# The --link option maps hard links, enabling upgrades in seconds.
$NEW_BIN/pg_upgrade \
--old-datadir="$OLD_DATA" \
--new-datadir="$NEW_DATA" \
--old-bindir="$OLD_BIN" \
--new-bindir="$NEW_BIN" \
--link
log "Step 5: Copy configuration overrides"
cp "$OLD_DATA/pg_hba.conf" "$NEW_DATA/pg_hba.conf"
cp "$OLD_DATA/postgresql.conf" "$NEW_DATA/postgresql.conf"
log "Step 6: Start the new cluster on a temporary port"
pg_ctl -D "$NEW_DATA" -o "-p $TEMP_PORT" start
log "Step 7: Recreate replication slots on the upgraded cluster"
# We read the saved metadata to restore the replication slots
if [ -f /tmp/replication_slots.txt ]; then
while IFS='|' read -r slot_name plugin slot_type; do
if [ -n "$slot_name" ]; then
log "Recreating replication slot: $slot_name"
if [ "$slot_type" = "logical" ]; then
psql -p "$TEMP_PORT" -d postgres -c \
"SELECT pg_create_logical_replication_slot('$slot_name', '$plugin');"
else
psql -p "$TEMP_PORT" -d postgres -c \
"SELECT pg_create_physical_replication_slot('$slot_name');"
fi
fi
done < /tmp/replication_slots.txt
fi
log "Step 8: Perform statistics rebuild"
# Upgraded clusters do not retain optimizer statistics.
# We run analyze-in-stages to build basic statistics before accepting traffic.
$NEW_BIN/vacuumdb -p "$TEMP_PORT" --all --analyze-in-stages
log "Step 9: Stop temporary instance and start on production port"
pg_ctl -D "$NEW_DATA" stop -m fast
pg_ctl -D "$NEW_DATA" -o "-p $PG_PORT" start
log "Postgres upgrade pipeline completed successfully."
High-Performance Go Verification Client
When performing database cutovers, application connection pools must handle temporary database unavailability, retry failed queries, and verify connection paths. The Go program below connects to both old and new clusters to test read and write operations before, during, and after a cutover.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
// UpgradeVerifier manages connections to both source and target database clusters.
type UpgradeVerifier struct {
oldDB *sql.DB
newDB *sql.DB
}
// NewUpgradeVerifier initializes the connection pools.
func NewUpgradeVerifier(oldDSN, newDSN string) (*UpgradeVerifier, error) {
oldDB, err := sql.Open("pgx", oldDSN)
if err != nil {
return nil, fmt.Errorf("failed to open old database connection: %w", err)
}
oldDB.SetMaxOpenConns(10)
oldDB.SetConnMaxLifetime(5 * time.Minute)
newDB, err := sql.Open("pgx", newDSN)
if err != nil {
oldDB.Close()
return nil, fmt.Errorf("failed to open new database connection: %w", err)
}
newDB.SetMaxOpenConns(10)
newDB.SetConnMaxLifetime(5 * time.Minute)
return &UpgradeVerifier{
oldDB: oldDB,
newDB: newDB,
}, nil
}
// Close terminates connection pools.
func (uv *UpgradeVerifier) Close() {
uv.oldDB.Close()
uv.newDB.Close()
}
// TestReadWritePath executes test writes and reads to verify cluster consistency.
func (uv *UpgradeVerifier) TestReadWritePath(ctx context.Context, useNewCluster bool) (time.Duration, error) {
targetDB := uv.oldDB
clusterName := "old_cluster"
if useNewCluster {
targetDB = uv.newDB
clusterName = "new_cluster"
}
start := time.Now()
// Execute write testing
var lastInsertID int
err := targetDB.QueryRowContext(ctx, `
INSERT INTO upgrade_canary (payload, validated_at)
VALUES ($1, clock_timestamp())
RETURNING id;
`, fmt.Sprintf("testing validation on %s", clusterName)).Scan(&lastInsertID)
if err != nil {
return 0, fmt.Errorf("write error on %s: %w", clusterName, err)
}
// Execute read verification
var payload string
err = targetDB.QueryRowContext(ctx, `
SELECT payload
FROM upgrade_canary
WHERE id = $1;
`, lastInsertID).Scan(&payload)
if err != nil {
return 0, fmt.Errorf("read verification error on %s: %w", clusterName, err)
}
duration := time.Since(start)
return duration, nil
}
// WaitAndVerifyCutover polls the database during cutover, retrying connections until the new cluster is active.
func (uv *UpgradeVerifier) WaitAndVerifyCutover(ctx context.Context, retryLimit int, retryInterval time.Duration) error {
log.Printf("Starting database cutover verification...")
for i := 1; i <= retryLimit; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
duration, err := uv.TestReadWritePath(ctx, true)
if err == nil {
log.Printf("Cutover validation succeeded. Query round-trip: %v", duration)
return nil
}
log.Printf("Verification trial %d/%d failed: %v. Retrying in %v...", i, retryLimit, err, retryInterval)
time.Sleep(retryInterval)
}
return errors.New("cutover verification failed: target cluster did not become reachable in time")
}
func main() {
oldDSN := "postgres://postgres:securepassword@localhost:5432/upgrade_db?sslmode=disable"
newDSN := "postgres://postgres:securepassword@localhost:5433/upgrade_db?sslmode=disable"
verifier, err := NewUpgradeVerifier(oldDSN, newDSN)
if err != nil {
log.Fatalf("Verifier initialization failed: %v", err)
}
defer verifier.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Verify the active write path on the source cluster before starting the upgrade
dur, err := verifier.TestReadWritePath(ctx, false)
if err != nil {
log.Printf("Warning: Source cluster write test failed: %v", err)
} else {
log.Printf("Source cluster write test succeeded in %v", dur)
}
// Verify and retry connectivity to the new target cluster during database cutover
err = verifier.WaitAndVerifyCutover(ctx, 30, 2*time.Second)
if err != nil {
log.Fatalf("Cutover verification failed: %v", err)
}
}
Database Metrics Comparison Table
The choice between running upgrades in copy mode versus link mode represents a trade-off between upgrade duration and fallback safety. The table below outlines these metrics across database sizes of 100 Gigabytes, 1 Terabyte, and 5 Terabytes.
| Database Size | Upgrade Execution Mode | pg_upgrade Run Duration | Total System Downtime | Additional Storage Overhead | Post-Upgrade Query Warm-up Latency |
|---|---|---|---|---|---|
| 100 GB | Copy Mode | 18 min | 22 min | 100 GB | 145 ms |
| 100 GB | Link Mode (--link) | 8 sec | 45 sec | < 1 MB | 150 ms |
| 1 TB | Copy Mode | 195 min | 210 min | 1.0 TB | 420 ms |
| 1 TB | Link Mode (--link) | 12 sec | 58 sec | < 1 MB | 455 ms |
| 5 TB | Copy Mode | 920 min (saturated) | 985 min | 5.0 TB | 1,850 ms |
| 5 TB | Link Mode (--link) | 15 sec | 62 sec | < 1 MB | 1,980 ms |
Note: In link mode, the execution time of pg_upgrade is independent of database size. This is because the utility only writes new system catalog files and creates hard links to the existing data files on disk.
What Breaks in Production
Link Mode Filesystem Discrepancies
pg_upgrade in link mode relies on creating hard links to map data files from the old directory structure to the new one.
- Mechanisms of Failure: Hard links cannot be created across different filesystems or block storage mount points. If the new PostgreSQL data directory is mounted on a separate disk volume from the old data directory, the hard link system calls will fail with an
EXDEVerror (Invalid cross-device link). If this occurs, the upgrade process terminates, leaving the cluster in an inconsistent state. - Remediation: Ensure both the old and new data directories reside on the same physical volume and share a common mount point. If directories must be separated, use copy mode (which requires copying all files and increases downtime) or configure logical replication instead.
Replication Slot Drops and Replica Rebuilding
Logical and physical replication slots are not automatically migrated during major version upgrades.
- Mechanisms of Failure: When
pg_upgradefinishes, all replication slots on the upgraded cluster are dropped. If standby replicas attempt to connect to the upgraded cluster, they cannot locate their replication slots, causing synchronization to fail. Rebuilding a secondary replica on a multi-terabyte cluster from scratch generates significant network traffic and leaves the primary database running without redundancy for hours. - Remediation: Extract replication slot metadata prior to stopping the old cluster. Once the upgrade utility completes, recreate the slots on the target cluster before accepting client write traffic. For physical replicas, run
pg_rewindor use delta synchronization tools to point standby replicas to the new cluster without performing a full base backup.
Query Performance Regressions from Stale Statistics
PostgreSQL optimizer statistics are stored in system catalogs and are not carried over during major upgrades.
- Mechanisms of Failure: When the upgraded database starts up, the query planner has no statistics about table sizes, index selectivity, or data distributions. As a result, the planner may generate inefficient execution plans (such as choosing nested loop joins instead of hash joins or performing sequential scans instead of index scans). This can cause query response times to spike and exhaust database connections.
- Remediation: Run
vacuumdb --all --analyze-in-stagesimmediately after completing the upgrade and before opening the cluster to application traffic. This rebuilds the statistics in stages, starting with a fast, low-sample scan to generate basic statistics, followed by deeper analyses to populate the query optimizer.
Shared Library and Extension Version Mismatch
If the old cluster utilizes external extensions (such as PostGIS or pgvector), the new cluster must have matching binaries installed in its library directories.
- Mechanisms of Failure: If extension binaries are missing or have mismatched versions in the new PostgreSQL install,
pg_upgradewill abort during the compatibility check phase. If you bypass these checks and force the upgrade, queries referencing extension objects will fail with runtime errors once the database starts up. - Remediation: Install matching versions of all database extensions on the new cluster environment before running the upgrade. Verify that all shared library paths match by checking the output of
pg_upgrade --check.
FAQs
What is the primary benefit of this design pattern?
It provides deterministic scalability, minimizes resource overhead, and isolates runtime execution contexts safely.
How do we verify the performance improvements?
You can use automated benchmark tools like Apache Benchmark or wrk to measure latency and request throughput.