A pattern for zero-downtime data swaps using two parallel schemas within a single database.
Two schemas (blue, green) coexist in one database. Traffic routes to one via search_path. The inactive schema is rebuilt while the active one serves queries. At time T, the switch is made.
Key constraint: app code must never schema-qualify table names — all routing is via search_path.
CREATE SCHEMA blue;
CREATE SCHEMA green;
GRANT USAGE ON SCHEMA blue, green TO app_role;
ALTER DATABASE mydb SET search_path TO green, public;
SELECT cron.schedule('flip-schema', '0 0 1 4 *',
$ALTER DATABASE mydb SET search_path TO green, public$);
App evaluates a switch time on each connection checkout:
func schemaForNow(switchAt time.Time) string {
if time.Now().After(switchAt) {
return "green"
}
return "blue"
}
// On connection checkout:
conn.Exec("SET search_path TO " + schemaForNow(switchAt) + ", public")
Switch time can be read from config/DB so it’s adjustable without redeployment.
Combine both layers:
search_path on each connection checkoutThis gives automatic cutover, no stale connections, and instant rollback.
ALTER DATABASE mydb SET search_path TO blue, public;
Sub-second. Old schema is untouched until explicitly dropped.
[active: blue] → build green → flip to green → drain pool
→ blue now idle
→ rebuild blue for next cycle
Drop when no longer needed as rollback target:
DROP SCHEMA blue CASCADE;
| Concern | Notes |
|---|---|
| Sequences | Schema-local sequences reset on rebuild; use shared sequences in public if continuity matters |
| Foreign keys | Cross-schema FKs work but complicate DROP SCHEMA; avoid |
| Connection pooling | PgBouncer caches search_path; must flush pool or use startup query |
| In-flight transactions | Transactions open at T straddle the switch; design for idempotency around cutover |
| Schema-qualified queries | Any hardcoded blue.tablename in app or migrations will break the abstraction |
Less suited for high-write OLTP where coordinating in-flight writes across the cutover adds complexity.