Written by
Halkwinds Editorial Team
Halkwinds Research & Editorial

Database Migrations at Scale: Zero Downtime Techniques
How to evolve your database schema without taking downtime — expand-contract, online DDL, and the tooling that makes it safe.
Every engineering manager who has been on call during a production release knows the particular dread of a schema change gone wrong. A migration that runs for 40 minutes and locks a hot table. A rollback that never actually rolls back. An ALTER TABLE that seemed harmless in staging but brought the checkout flow to its knees under real traffic. As your data grows, the naive approach — take a maintenance window, run the migration, hope for the best — stops being an option. Your users expect the service to be available at 2 a.m. on a Tuesday, and your SLA agreement probably says so too. This article walks through the concrete techniques teams use to evolve database schemas without downtime: the expand-contract pattern, online DDL, and the tooling that keeps the whole thing safe.
- Background / Why This Matters
- Prerequisites and Planning
- Step-by-Step Implementation
- Testing and Validation
- Common Mistakes / What to Avoid
- Frequently Asked Questions
- Conclusion
Background / Why This Matters
A database migration is any change to the structure or content of your database: adding a column, renaming a table, backfilling data, adding an index, or changing a constraint. The problem is that many of these operations acquire locks. In older versions of PostgreSQL, adding a column with a non-null default rewrote the entire table while holding an ACCESS EXCLUSIVE lock — meaning no reads and no writes until it finished. On a 200-million-row table, that is not a five-second operation.
Zero downtime matters because the cost of downtime scales with your business. Estimates vary widely across industries, but for most revenue-generating SaaS products, even a few minutes of unavailability during peak hours translates directly into lost transactions, support tickets, and eroded trust. Beyond revenue, frequent maintenance windows slow your team down: if every schema change requires coordination, approvals, and a late-night deploy, you ship less and your engineers burn out.
The strategic shift is this: treat schema evolution as a continuous, backward-compatible process rather than a series of disruptive events. When migrations are safe by default, developers stop fearing them, and you can decouple deploys from schema changes entirely.
Actionable takeaway: Audit your last ten migrations. If any of them required a maintenance window, that is your baseline problem to solve — not a one-off.
Prerequisites and Planning
Before you touch a single ALTER statement, get these foundations in place.
1. A version-controlled migration tool
Never run schema changes by hand against production. You need a tool that versions migrations, applies them idempotently, and records what has run. The common choices:
- Flyway — SQL-first, minimal, and language-agnostic. Great when your team is comfortable writing raw DDL and wants tight control.
- Liquibase — supports XML, YAML, JSON, and SQL changelogs, with richer rollback and preconditions. Better for complex enterprise environments needing database-agnostic definitions.
- Django migrations — tightly integrated with the Django ORM, auto-generated from model changes, ideal if you are already in that ecosystem.
2. Know your database's locking behavior
You cannot plan a safe migration without understanding what locks each operation takes. PostgreSQL, for example, has made huge strides: since version 11, adding a column with a constant default no longer rewrites the table, and CREATE INDEX CONCURRENTLY builds indexes without blocking writes. Read your specific version's documentation before assuming an operation is safe.
3. Application deploy strategy that supports rolling updates
Zero-downtime migrations only work if old and new versions of your application can run simultaneously against the same schema, even briefly. That means blue-green or rolling deploys, not a hard cutover.
4. A staging environment with production-like data volume
A migration that takes 2 seconds against 10,000 rows might take 20 minutes against 200 million. If your staging database is a toy, you are testing nothing. This is an area where Halkwinds' Engineering team frequently helps clients — building realistic, anonymized staging environments so migration timings are trustworthy before they hit production.
Actionable takeaway: Establish a rule that no schema-changing PR merges without an estimated lock time and row count for the affected tables.
Step-by-Step Implementation
The core technique for zero-downtime schema evolution is the expand-contract pattern (also called parallel change). Instead of changing a schema in one destructive step, you split it into backward-compatible phases.
The expand-contract pattern
- Expand: Add the new schema element alongside the old one. The new column, table, or index coexists with what already exists. Nothing breaks because old code still uses the old structure.
- Migrate: Update the application to write to both old and new structures (dual-write), then backfill historical data into the new structure in batches.
- Contract: Once all code reads from and writes to the new structure and the backfill is complete, remove the old structure in a final cleanup migration.
Worked example: renaming a column without downtime
Renaming users.email_address to users.email looks trivial but is dangerous — a single ALTER TABLE ... RENAME COLUMN immediately breaks any running instance of your app that still references the old name. Here is the safe sequence:
- Migration 1 (expand): Add the new
emailcolumn. In PostgreSQL 11+ this is fast and non-blocking for a nullable column. - Deploy app v2: Application writes to both
emailandemail_addressand reads fromemail_addressifemailis null. - Migration 2 (backfill): Copy data in batches — for example,
UPDATE users SET email = email_address WHERE email IS NULL AND id BETWEEN x AND y— using small batch sizes (1,000–10,000 rows) with pauses to avoid replication lag and long-held locks. - Deploy app v3: Application now reads exclusively from
email. - Migration 3 (contract): Drop the
email_addresscolumn.
Online DDL: let the database do the heavy lifting
For some operations, modern databases offer built-in non-blocking equivalents. The most important in PostgreSQL:
CREATE INDEX CONCURRENTLY— builds an index without blocking writes. Note it cannot run inside a transaction, so you must configure your migration tool to run it outside one (Django usesAddIndexConcurrently; Flyway needs a non-transactional migration).ALTER TABLE ... ADD CONSTRAINT ... NOT VALIDfollowed byVALIDATE CONSTRAINT— adds a constraint without a long lock, then validates it in a separate, less restrictive step.- Setting
lock_timeoutbefore running DDL so a migration that cannot acquire its lock quickly fails fast instead of blocking a queue of queries behind it.
Comparison of common operations
| Operation | Naive approach | Zero-downtime approach |
|---|---|---|
| Add nullable column | Safe on modern PostgreSQL | Just do it (verify version) |
| Add NOT NULL column | Table rewrite + long lock | Add nullable, backfill, add NOT NULL constraint as NOT VALID then validate |
| Rename column | Instant break of old app version | Expand-contract with dual-write |
| Add index | Blocks writes during build | CREATE INDEX CONCURRENTLY |
| Drop column | Fast lock, but breaks app if still referenced | Remove all code references first, then drop |
| Change column type | Full table rewrite + lock | New column + backfill + swap via expand-contract |
Actionable takeaway: Codify the expand-contract steps as a checklist template in your PR review process. Every risky migration should reference which phase it belongs to.
Testing and Validation
A migration strategy is only as good as your confidence in it. Testing zero-downtime migrations requires more than "it ran without an error."
Test against realistic data volume
Run the migration against a copy of production-scale data and measure lock duration explicitly. In PostgreSQL you can query pg_locks and pg_stat_activity during a dry run to see exactly what locks are held and for how long.
Test backward compatibility with concurrent versions
Because a rolling deploy means old and new app versions run at once, explicitly test the intermediate state. Deploy app v1 and app v2 side by side against the expanded schema and confirm neither errors. This catches the classic failure where a "contract" step runs before all old instances have drained.
Test rollback
The safest migrations are those you never need to reverse mid-flight — a key advantage of expand-contract is that each step is independently reversible. Still, verify that Liquibase or Flyway rollback scripts actually restore the prior state, and remember that a backfill of millions of rows cannot always be cleanly undone. Prefer forward-fixing over rollback for data changes.
Monitor in production during the change
Watch replication lag, connection pool saturation, and query latency in real time as backfills run. If replication lag climbs, slow your batch rate. Tools like your APM (Datadog, New Relic) plus native database metrics give you the signal.
Actionable takeaway: Add a step to your runbook that captures baseline p95 query latency before the migration and defines a threshold at which you pause or abort the backfill.
Common Mistakes / What to Avoid
- Wrapping long-running DDL in a single transaction. A migration that holds locks for the duration of a huge transaction defeats the purpose. Break backfills into batches with separate commits.
- Adding a NOT NULL column with a default on old database versions. Always confirm your engine's behavior; what is instant on PostgreSQL 14 rewrites the table on older versions and some other databases.
- Skipping the dual-write phase. Renaming or restructuring without dual-write guarantees a window where one app version is broken.
- Contracting
Explore Further