Zero-Downtime Database Migrations: Patterns That Actually Work
Every developer has been there — you need to rename a column, change a data type, or add a NOT NULL constraint, but your application serves thousands of requests per second and any downtime means lost revenue. Zero-downtime database migrations solve this by restructuring your schema while the application continues running. Therefore, this guide covers the patterns, tools, and real-world strategies that let you evolve your database without taking your application offline.
Why Traditional Migrations Break Production
The fundamental problem is that schema changes and application code must be coordinated. When you run ALTER TABLE users RENAME COLUMN name TO full_name, every running instance of your application immediately breaks because it still queries the old column name. Moreover, long-running ALTER TABLE operations on large tables can lock the entire table for minutes or hours, blocking all reads and writes.
Consider a table with 50 million rows. Adding a column with a default value in older PostgreSQL versions rewrites every row, holding an ACCESS EXCLUSIVE lock the entire time. During this lock, no queries can execute — not even SELECT statements. For a table that handles 5,000 queries per second, that means 5,000 failed requests per second for however long the migration takes.
The solution is to break every dangerous migration into a series of safe, backwards-compatible steps. Each step can be deployed independently, and the application works correctly at every intermediate state. This is the expand-contract pattern.
The Expand-Contract Pattern for Zero-Downtime Migrations
The expand-contract pattern splits every breaking change into three phases: expand (add new structure), migrate (copy data), and contract (remove old structure). At no point does the application break because both old and new structures coexist during the transition.
Here is a concrete example — renaming a column from name to full_name:
-- Phase 1: EXPAND — Add the new column (non-blocking)
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
-- Create a trigger to keep both columns in sync
CREATE OR REPLACE FUNCTION sync_user_name() RETURNS trigger AS $
BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
IF NEW.full_name IS NULL AND NEW.name IS NOT NULL THEN
NEW.full_name := NEW.name;
ELSIF NEW.name IS NULL AND NEW.full_name IS NOT NULL THEN
NEW.name := NEW.full_name;
END IF;
END IF;
RETURN NEW;
END;
$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_user_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();
-- Phase 2: MIGRATE — Backfill existing rows (in batches)
UPDATE users SET full_name = name
WHERE full_name IS NULL
AND id BETWEEN 1 AND 100000;
-- Repeat in batches to avoid long-running transactions
-- Phase 3: CONTRACT — After ALL app instances use full_name
-- Deploy app code reading full_name instead of name
-- Wait for all old instances to drain
DROP TRIGGER trg_sync_user_name ON users;
DROP FUNCTION sync_user_name();
ALTER TABLE users DROP COLUMN name;
Each phase is a separate deployment. The expand phase adds infrastructure without breaking anything. The migrate phase copies data while the application reads from either column. The contract phase cleans up after every application instance has been updated. Additionally, if anything goes wrong during any phase, you can roll back without data loss because the old structure still exists.
Batching Backfills Without Killing Your Database
The migrate phase is where well-intentioned migrations quietly cause incidents. A single UPDATE users SET full_name = name across 50 million rows runs as one enormous transaction: it holds locks for the duration, bloats the write-ahead log, and on PostgreSQL leaves behind millions of dead tuples that autovacuum must later reclaim. The fix is to chunk the work into small, committed batches and to pace them so replication and disk I/O can keep up.
-- Backfill in bounded batches, committing each one
DO $
DECLARE
batch_size INT := 5000;
rows_updated INT;
BEGIN
LOOP
UPDATE users
SET full_name = name
WHERE id IN (
SELECT id FROM users
WHERE full_name IS NULL AND name IS NOT NULL
ORDER BY id
LIMIT batch_size
FOR UPDATE SKIP LOCKED -- don't fight concurrent writers
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
COMMIT; -- release locks, let WAL flush
PERFORM pg_sleep(0.1); -- throttle to protect replicas
END LOOP;
END $;
Three details make this safe. The LIMIT keeps each transaction short, so locks are held for milliseconds rather than hours. FOR UPDATE SKIP LOCKED ensures the backfill cooperates with live traffic instead of blocking it. And the small sleep between batches gives read replicas time to apply the changes, preventing replication lag from spiking and starving read-heavy services. In production teams typically run the backfill as a background job and watch replica lag as the throttle signal, slowing down whenever lag climbs.
Common Migration Patterns with Zero Downtime
Beyond column renames, several other schema changes require careful handling. Adding a NOT NULL constraint is deceptively dangerous — in PostgreSQL, ALTER TABLE ... SET NOT NULL scans the entire table to verify no NULLs exist, holding a lock the whole time. The safe approach uses a CHECK constraint instead:
-- Safe NOT NULL: use CHECK constraint (non-blocking in PG 12+)
ALTER TABLE orders
ADD CONSTRAINT orders_status_not_null
CHECK (status IS NOT NULL) NOT VALID;
-- Validate separately (takes ShareUpdateExclusiveLock, not blocking writes)
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_not_null;
-- Now the official NOT NULL is instant because PG knows the constraint holds
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT orders_status_not_null;
Changing a column type is another common need. You cannot safely cast a VARCHAR to an INTEGER in place. Instead, add a new INTEGER column, backfill it with converted data, update the application to write to both columns, then drop the old one. Consequently, every type change becomes an expand-contract operation.
Index creation should always use CREATE INDEX CONCURRENTLY, which builds the index without blocking writes. The downside is that concurrent index creation takes 2-3x longer and cannot run inside a transaction. If it fails, you must drop the invalid index and retry.
Adding Foreign Keys and Enums Safely
Two more changes catch teams off guard because their “obvious” form takes a heavy lock. Adding a foreign key with a plain ADD CONSTRAINT ... REFERENCES validates every existing row under a lock that blocks writes on both tables. As with NOT NULL, the trick is to separate adding the constraint from validating it, so writes keep flowing while the scan happens under a lighter lock.
-- Step 1: add the FK but skip the full-table validation (fast, light lock)
ALTER TABLE orders
ADD CONSTRAINT orders_customer_fk
FOREIGN KEY (customer_id) REFERENCES customers (id)
NOT VALID;
-- Step 2: validate existing rows separately, without blocking writes
ALTER TABLE orders VALIDATE CONSTRAINT orders_customer_fk;
Enum changes are subtler. On PostgreSQL, ALTER TYPE ... ADD VALUE is fast, but historically it could not run inside a transaction block, and you cannot remove or reorder enum values at all. For schemas that change often, a lookup table with a foreign key is more flexible than a native enum, because you can insert, deprecate, and soft-delete values with ordinary DML. The general rule holds across all of these cases: prefer the operation that can be split into a cheap metadata change plus a separately scheduled, non-blocking validation.
Flyway and Liquibase for Migration Management
Migration tools like Flyway and Liquibase track which migrations have been applied and ensure they run in order. However, neither tool enforces zero-downtime patterns — that responsibility falls on you. The tool manages versioning; you manage safety.
// Flyway migration file: V15__add_full_name_column.sql
// Phase 1: Expand — safe to run with app serving traffic
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
// V16__backfill_full_name.sql
// Phase 2: Migrate — run during low traffic
UPDATE users SET full_name = name WHERE full_name IS NULL;
// V17__drop_name_column.sql
// Phase 3: Contract — run AFTER all app instances updated
// WARNING: Only run after verifying no app code references 'name'
ALTER TABLE users DROP COLUMN name;
Liquibase offers a rollback mechanism that Flyway lacks in its free tier. However, rollbacks for schema changes are inherently risky — you cannot un-drop a column with its data. Therefore, the best strategy is forward-only migrations with the expand-contract pattern, where every state is valid and you never need to roll back. To make this enforceable rather than aspirational, many teams add a linter to CI. Tools such as Squawk inspect migration SQL and fail the build when they spot a dangerous statement — a bare SET NOT NULL, a non-concurrent index, or a default-value add on a large table — so the unsafe pattern is caught in code review instead of in production at 2 AM.
Blue-Green Database Migrations for Large Changes
Some changes are too complex for expand-contract — restructuring a normalized schema into a denormalized one, splitting a table, or migrating between database engines. For these, blue-green database migrations create a parallel database with the new schema, replicate data continuously, and switch traffic atomically.
The workflow uses change data capture (CDC) to stream changes from the old database to the new one. Tools like Debezium capture every INSERT, UPDATE, and DELETE from the old database’s transaction log and apply them to the new database in real-time. When the new database is caught up, you switch the application’s connection string. Moreover, you can keep replication running in reverse for a quick rollback.
This approach works for cross-engine migrations too — moving from MySQL to PostgreSQL, or from a monolithic database to sharded instances. The application never experiences downtime because the switch is a DNS or connection string change, and the new database already has all the data.
When Zero-Downtime Is Not Worth It
It would be dishonest to present these patterns as a free lunch. Expand-contract turns one migration into three deployments, a sync trigger, a paced backfill, and a coordination dance with your rollout — that is real engineering cost and real added complexity. For a great many systems, that cost is not justified. An internal admin tool used by a dozen people, a batch pipeline with a nightly maintenance window, or an early-stage product with a small table can simply take a brief, scheduled outage and run the obvious ALTER TABLE. A planned thirty-second maintenance window at 3 AM is often safer and far cheaper than a multi-week migration choreography.
The honest decision rule is to weigh the blast radius of downtime against the engineering cost of avoiding it. Reserve the full machinery — triggers, batched backfills, CDC, blue-green cutovers — for tables that are large, hot, and on the critical path of a system where seconds of unavailability translate directly into lost revenue or breached SLAs. Everywhere else, the simplest migration that is correct beats the cleverest one that is fragile.
Practical Checklist for Safe Migrations
Before running any migration in production, verify these points:
- Can the current application code work with the new schema? If not, deploy the code change first.
- Does the migration acquire locks? Use
pg_locksin a test environment to verify. - How long does it take on production-sized data? Test with a copy of production.
- Is there a safe rollback? Document it before running the migration.
- Are there long-running transactions that could block the migration? Check
pg_stat_activity. - Did you set a
lock_timeoutso a blocked DDL statement fails fast instead of queueing behind every query?
That last point deserves emphasis. Setting SET lock_timeout = '3s' before a DDL statement means that if the migration cannot acquire its lock quickly, it aborts rather than blocking — and, critically, rather than letting a queue of waiting queries pile up behind it and take the whole table offline. It is a one-line guardrail that turns a potential outage into a harmless, retryable failure.
Related Reading:
- SQL Query Optimization for PostgreSQL
- Database Sharding Strategies
- Connection Pooling with PgBouncer
Resources:
In conclusion, zero-downtime database migrations require discipline but follow repeatable patterns. The expand-contract approach handles 90% of schema changes safely, batched backfills and lock timeouts protect you from the operational footguns, and blue-green migrations with CDC cover the remaining hard cases. The key insight is that every migration is a series of backwards-compatible steps, never a single breaking change — and that knowing when a simple maintenance window is good enough is part of the discipline too.