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.
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.
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
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 10psql 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
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---1ERROR: cannot execute INSERT in a read-only transaction - 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 primarypsql 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------------tpg_is_in_recovery-------------------fINSERT 0 1 - 4
A safe NOT NULL, the expand/contract way
Adding
NOT NULLdirectly 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
| Setting | Default | What it does | When 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_feedback | off | Replica 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.ttl | 30s 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.
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.
Decide
Replicas: HA standby vs read replica
| Multi-AZ standby / Patroni sync replica | Read replica | |
|---|---|---|
| Purpose | Take over if the primary fails | Offload read queries |
| Serves reads? | No (RDS Multi-AZ instance) / optional | Yes |
| Replication | Synchronous (no data loss) | Asynchronous (lag) |
| Failover | Automatic | Manual promotion |
| Typical use | Every production database | Reporting, read-heavy APIs, cross-region DR |
The bigger picture
Connects to
System Design · Replication
Copies of the same data on multiple nodes: primary handles writes, replicas serve reads and take over on failure.
System Design · Eventual Consistency
Replicas converge eventually — the default of any replicated system that doesn't synchronize every read.
GitOps · Sync waves & hooks
Running migrations as PreSync hooks before new code starts.
CI/CD · Deployment strategies
Why old and new code share the database during rollouts.
SRE · AZ outage
A zone fails and the database fails over: the on-call view.
AWS · Cost & resilience
Multi-AZ vs multi-region and what each costs.
Prove it
Interview questions
How do you rename a column with zero downtime?
What's the difference between RDS Multi-AZ and a read replica?
How would you do a Postgres major version upgrade with minimal downtime?