← all writeups
Postgres 12 to 16 with no downtime
Logical replication, a shadow cluster, and a cutover rehearsed eleven times before the one that counted.
- Date
- 2026-04-22
- Type
- build
- Duration
- 4 weeks
- Impact
- Upgraded a 1.2 TB primary; writes paused for under 4 seconds at cutover.
PLACEHOLDER POST — seed data. Replace with your real writeup.
Postgres 12 was out of support. The in-place pg_upgrade path meant roughly 40
minutes of downtime on a database that takes orders around the clock, so that was
off the table.
The approach
- Stand up a Postgres 16 cluster from the schema only
- Create a publication on 12 and a subscription on 16, then let the initial copy run (about 19 hours)
- Keep them in sync, and run read-only shadow traffic against 16 to compare results
- Cut over behind PgBouncer
The cutover, in order
-- on 12: stop writes at the pooler, then confirm the replica has caught up
SELECT pg_current_wal_lsn();
-- on 16: wait until the subscription's received LSN matches
SELECT received_lsn, latest_end_lsn FROM pg_stat_subscription;
-- sequences are NOT replicated — copy them explicitly
SELECT setval('orders_id_seq', :last_value + 1000);
After that, PgBouncer’s config pointed at the new host and we ran RELOAD, then
RESUME.
Things that would have bitten us
- Sequences don’t replicate. Rehearsal #3 hit duplicate key errors.
- Tables without primary keys can’t replicate updates. We found four.
- Large objects aren’t supported by logical replication. We had one, and it was unused.
Eleven rehearsals sounds like a lot. The real cutover was the most boring ten minutes of the quarter.
← all writeups