Command Palette

Search for a command to run...

Unit 1.3 · Databases in Production: PostgreSQL

Replication, Failover, Upgrades, and Zero-Downtime Migrations

Streaming replication and lag, read replicas vs high availability, Patroni and RDS Multi-AZ failover, major version upgrades, and the expand/contract pattern for schema changes.

Advanced 60 min 4 lab steps 2 failure drills

Start here

The mental model

Two different goals use replication. HIGH AVAILABILITY: a standby ready to take over in seconds if the primary dies (RDS Multi-AZ, Patroni). READ SCALING: extra copies that serve read-only queries (read replicas). The same technology, streaming WAL, serves both, but they're configured and reasoned about differently.

Schema changes are the most common cause of self-inflicted database outages. The safe way is to never make a change the currently running code can't handle: add first, migrate, switch, then remove. That's expand/contract.

Go deeper

How it works inside

01Streaming replication and lag

A replica connects to the primary, receives WAL as it's written, and replays it. pg_stat_replication on the primary shows each replica's sent, written, flushed, and replayed positions; the difference is REPLICATION LAG, in bytes or time. Lag grows when the replica's disk or CPU can't keep up, the network is slow, or a long query on the replica conflicts with replay (hot_standby_feedback and max_standby_streaming_delay govern that trade-off).

Reading from an async replica means reading slightly old data. The classic bug: a user updates their profile (write to the primary), the page reloads from a replica 200 ms behind, and the change 'didn't save'. Route read-your-own-writes traffic to the primary, or use a session flag to stick to it briefly after a write.

02Failover

RDS MULTI-AZ keeps a synchronous standby in another AZ and fails over automatically (typically 60–120 s; Multi-AZ DB clusters and Aurora are faster) by flipping the DNS endpoint. Apps must reconnect, and DNS caching must be short (JVM networkaddress.cache.ttl). PATRONI does this for self-managed Postgres: each node runs an agent, a leader lock lives in etcd/Consul/Kubernetes, and only the lock holder is primary, which prevents split brain (Unit 0.1). CloudNativePG does the equivalent natively on Kubernetes.

Failoverdiagram
Rendering diagram…

03Major version upgrades

Minor versions (17.5 → 17.6) are binary compatible: a restart. Major versions (16 → 17) change the on-disk format. Options: pg_upgrade (in place, fast with --link, requires downtime), dump and restore (slow for big data), or LOGICAL REPLICATION to a new-version cluster followed by a quick cutover. The last is the near-zero-downtime path, and what RDS Blue/Green Deployments automate. Always test extensions, run ANALYZE afterwards (statistics aren't carried over), and rehearse on a snapshot copy.

04Expand/contract migrations

During a rolling or canary deploy (GitOps course, Mission 2.2), OLD and NEW code run at the same time against the SAME database, so every migration must work with both. To rename name to full_name: (1) EXPAND: add full_name nullable, and deploy code that writes both columns; (2) MIGRATE: backfill full_name in batches; (3) SWITCH: deploy code that reads full_name; (4) CONTRACT: once no running code uses name, drop it in a later release. Each step is independently deployable and reversible.

Safe-by-default operations in Postgres 11+: adding a nullable column or one with a constant default (metadata only). Risky: adding NOT NULL without a validated CHECK first, changing a column type (table rewrite), creating an index without CONCURRENTLY, and large single-statement UPDATEs (backfill in batches of a few thousand rows instead).

Do it

Hands-on lab

  1. 1

    Start a primary and a streaming replica

    Using Docker Compose, the replica bootstraps itself from the primary with pg_basebackup -R, which writes the standby configuration.

    compose.yamlwhole fileyaml
    services:
      primary:
        image: postgres:17
        environment: { POSTGRES_PASSWORD: pw }
        command: ["postgres", "-c", "wal_level=replica", "-c", "max_wal_senders=5"]
        ports: ["5432:5432"]
      replica:
        image: postgres:17
        environment: { PGPASSWORD: pw }
        depends_on: [primary]
        user: postgres
        entrypoint: ["bash", "-c"]
        command:
          - |
            until pg_isready -h primary; do sleep 1; done
            rm -rf /var/lib/postgresql/data/*
            pg_basebackup -h primary -U postgres -D /var/lib/postgresql/data -R -X stream
            chmod 700 /var/lib/postgresql/data
            exec postgres
        ports: ["5433:5432"]
    terminal
    $ docker compose up -d && sleep 10
    psql postgresql://postgres:pw@localhost:5432/postgres -c 'select client_addr, state, replay_lag from pg_stat_replication'
    ── expected output ──
    client_addr | state | replay_lag
    -------------+-----------+------------
    172.20.0.3 | streaming | 00:00:00.000912
  2. 2

    Prove the replica is read-only and follows the primary

    terminal
    $ psql postgresql://postgres:pw@localhost:5432/postgres -c 'create table r(x int); insert into r values (1)'
    psql postgresql://postgres:pw@localhost:5433/postgres -c 'select * from r' -c 'insert into r values (2)'
    ── expected output ──
    x
    ---
    1
    ERROR: cannot execute INSERT in a read-only transaction
  3. 3

    Fail over manually

    Stop the primary and promote the replica. In production, Patroni or RDS do this and also redirect clients. Here you'd point the app at port 5433.

    terminal
    $ docker compose stop primary
    psql postgresql://postgres:pw@localhost:5433/postgres -c 'select pg_promote()' -c 'select pg_is_in_recovery()' -c 'insert into r values (2)'
    ── expected output ──
    pg_promote
    ------------
    t
    pg_is_in_recovery
    -------------------
    f
    INSERT 0 1
  4. 4

    A safe NOT NULL, the expand/contract way

    Adding NOT NULL directly scans the whole table under an exclusive lock. Instead: add a CHECK constraint as NOT VALID (instant), validate it (scans without blocking writes), then set NOT NULL, which Postgres 12+ can prove from the valid constraint without another scan.

    migrations/0042_orders_currency.sqlwhole filesql
    SET lock_timeout = '3s';
    ALTER TABLE orders ADD COLUMN currency text;                 -- expand (metadata only)
    -- app release N writes currency on every insert; backfill old rows in batches:
    -- UPDATE orders SET currency = 'INR' WHERE id BETWEEN $1 AND $2 AND currency IS NULL;
    ALTER TABLE orders ADD CONSTRAINT orders_currency_nn CHECK (currency IS NOT NULL) NOT VALID;
    ALTER TABLE orders VALIDATE CONSTRAINT orders_currency_nn;   -- no write lock
    ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;        -- uses the valid check
    ALTER TABLE orders DROP CONSTRAINT orders_currency_nn;

Operate it

Knobs that matter

SettingDefaultWhat it doesWhen to change it
synchronous_standby_names'' (async)Which standbys must confirm commits.ANY 1 (s1, s2) for zero data loss if a primary dies, at a latency cost.
hot_standby_feedbackoffReplica tells primary which rows its queries still need.On to stop replica query cancellations; can cause bloat on the primary.
max_slot_wal_keep_size-1 (unlimited)Caps WAL retained for replication slots.Set so a dead consumer can't fill the primary's disk.
JVM networkaddress.cache.ttl30s or forever (with a security manager)How long Java caches DNS.Keep ≤ 5–30s so apps follow RDS failover DNS changes.

3am practice

Failure drills

Each drill is a real failure mode. Read the scenario and the output, decide what's wrong, then reveal the diagnosis.

Drill #1

Replica lag climbs every night at 2am

Dashboards built on the read replica show yesterday's numbers every morning; replica lag peaks at 40 minutes.

terminal
$ psql -h primary -c 'select application_name, replay_lag from pg_stat_replication'
── what you'll see ──
application_name | replay_lag
------------------+-----------------
replica-1 | 00:41:07.220114

Drill #2

The app doesn't recover after an RDS failover

RDS Multi-AZ fails over in 70 s. Half the Java pods keep erroring for 20 minutes until restarted.

terminal
$ kubectl logs api-7d9f-x2k4 | tail -1
── what you'll see ──
org.postgresql.util.PSQLException: Connection to shoplite-prod.xxxx.ap-south-1.rds.amazonaws.com:5432 refused. ... ERROR: cannot execute INSERT in a read-only transaction

Decide

Replicas: HA standby vs read replica

Multi-AZ standby / Patroni sync replicaRead replica
PurposeTake over if the primary failsOffload read queries
Serves reads?No (RDS Multi-AZ instance) / optionalYes
ReplicationSynchronous (no data loss)Asynchronous (lag)
FailoverAutomaticManual promotion
Typical useEvery production databaseReporting, read-heavy APIs, cross-region DR

The bigger picture

Connects to

Prove it

Interview questions

01

How do you rename a column with zero downtime?

02

What's the difference between RDS Multi-AZ and a read replica?

03

How would you do a Postgres major version upgrade with minimal downtime?

0/3 · 0%