Database Migration Strategies: Moving Production Data Without Downtime
Zero-downtime database migrations are possible but require discipline — dual-write patterns, schema compatibility phases, and a rollback plan that you've actually tested. Here's what works at production scale.
The migration that causes an outage is usually not the one that failed — it's the one that succeeded but not in the way anyone expected. A column renamed. A constraint applied. A backfill that took six hours instead of forty minutes.
Zero-downtime database migrations are achievable, but they require treating schema changes and data movement as distinct operations with distinct risk profiles.
The core principle: schema changes must be backward compatible
The fundamental constraint: at any point during a deployment, both the old version of the application and the new version must be able to read and write the database correctly. This rules out a large category of apparently simple changes:
- Renaming a column breaks the old version immediately
- Adding a NOT NULL column without a default breaks any insert from the old version
- Dropping a column that the old version still reads causes errors
- Changing a column type without a compatibility guarantee breaks queries
The solution is to decompose "rename a column" into a sequence of backward-compatible steps:
- Add the new column (nullable, old version ignores it)
- Deploy code that writes to both old and new columns
- Backfill the new column from the old column
- Deploy code that reads from the new column, writes to both
- Verify the new column is complete and correct
- Deploy code that only uses the new column
- Drop the old column (now unreferenced)
This takes longer. It requires multiple deployments. It's the only approach that doesn't carry downtime risk.
The expand/contract pattern
The formalization of this approach is called expand/contract (also called parallel change):
Expand phase: add new structure alongside old structure. New columns, new tables, new relationships. Old code continues to work. New code can optionally use the new structure.
Migrate phase: backfill data, run dual-writes, verify correctness. The database contains both old and new representations simultaneously.
Contract phase: remove old structure once all code has migrated to the new representation and old structure is confirmed unused.
The contract phase is where most teams skip steps. Dropping a column that "nobody is using anymore" without confirming via query analysis or feature flags is how you get 3am incidents.
Backfill strategy for large tables
Backfilling a column on a 50-million-row table with a single UPDATE statement will:
- Lock rows for the duration
- Generate a large amount of WAL/redo log
- Potentially cause replication lag
- Potentially trigger autovacuum contention on PostgreSQL
The correct approach is batched backfill:
-- PostgreSQL batch backfill with row-level targeting
DO $$
DECLARE
batch_size INT := 10000;
last_id BIGINT := 0;
max_id BIGINT;
BEGIN
SELECT MAX(id) INTO max_id FROM orders;
WHILE last_id < max_id LOOP
UPDATE orders
SET new_column = derive_value(old_column)
WHERE id > last_id AND id <= last_id + batch_size
AND new_column IS NULL;
last_id := last_id + batch_size;
PERFORM pg_sleep(0.1); -- brief pause to reduce contention
END LOOP;
END $$;
Key parameters to tune:
- Batch size: 1,000–50,000 rows depending on row width and index contention
- Sleep interval: 50–200ms to let replication catch up and reduce lock contention
- Idempotency: the
AND new_column IS NULLguard means the script can be rerun safely if interrupted
Track progress with a dedicated monitoring query:
SELECT
COUNT(*) FILTER (WHERE new_column IS NOT NULL) AS migrated,
COUNT(*) AS total,
ROUND(100.0 * COUNT(*) FILTER (WHERE new_column IS NOT NULL) / COUNT(*), 1) AS pct
FROM orders;
Online schema change tools
For MySQL, Percona's pt-online-schema-change or GitHub's gh-ost handle the shadow table pattern automatically:
- Create a shadow table with the new schema
- Set up triggers to replicate ongoing changes
- Copy existing rows in batches
- Perform a near-instant table swap when caught up
gh-ost is generally preferred for production use because it uses binary log streaming rather than triggers, which avoids the write amplification problem with trigger-based approaches on high-write tables.
For PostgreSQL, pglogical replication or manual expand/contract is the standard approach. The pg_repack extension handles CLUSTER and VACUUM FULL equivalents online, but schema changes still require the manual pattern.
Cross-database migrations
Moving data between database engines (Oracle to PostgreSQL, SQL Server to Aurora) adds complexity:
Data type mapping: Oracle's VARCHAR2, DATE, and NUMBER types don't map cleanly. NUMBER(10) might be INTEGER, BIGINT, or DECIMAL depending on context. DATE in Oracle includes time; in PostgreSQL it doesn't.
Procedural code: PL/SQL doesn't port directly to PL/pgSQL. Function signatures, exception handling, cursor patterns, and built-in functions all differ. This code requires manual rewrite and testing, not automated conversion.
Sequence behavior: Oracle sequences and PostgreSQL sequences behave differently around caching and session-level allocation. Applications that depend on sequence monotonicity need explicit attention.
The migration phases for cross-database moves:
- Schema conversion: automated tools (AWS Schema Conversion Tool, ora2pg) handle the mechanical parts. Manual review handles the edge cases, which are numerous.
- Initial load: bulk transfer of historical data, typically via CSV export/import or a dedicated ETL tool.
- CDC (Change Data Capture): ongoing replication of changes during cutover preparation. AWS DMS, Debezium, or Striim are common options.
- Cutover: stop writes to source, verify CDC has caught up, redirect application connections to target, verify, open writes to target.
The cutover window is the exposure period. For most production systems, a 15–30 minute cutover window (stop writes, verify, redirect, verify again) is achievable with proper preparation.
Testing the rollback
The rollback plan that hasn't been tested is not a rollback plan. Before any significant migration, verify:
- The rollback procedure takes less than the defined RTO
- The rollback leaves data in a consistent state (dual-write helps here — the old table is still current)
- Someone has executed the rollback in a staging environment recently enough that it reflects the current schema state
The most common rollback failure mode: the rollback assumes the old schema still exists, but the contract phase already removed it.
Working through a database migration with a tight cutover constraint? The sequencing of expand/contract phases is where most of the risk sits. Happy to think through the specific trade-offs.
Related
Capacity Planning in the Cloud Era: Headroom as an Engineered Property
Elastic infrastructure didn't kill capacity planning — it changed what gets planned. Demand modeling, the utilization-vs-headroom trade, quota and constraint archaeology, and the quarterly loop that replaces both waste and 3 AM scrambles.
API Design for Enterprise Systems: The Decisions That Age Well
An API that's easy to build against is easy to build wrong. The decisions around versioning, error models, pagination, idempotency, and authentication compound over time — here's how to make the ones that don't cause regret.
System Design Patterns Every Backend Engineer Should Know
CQRS, event sourcing, saga pattern, outbox pattern — the patterns that solve specific distributed systems problems, what trade-offs they introduce, and when the simpler option is actually the right one.
Working on this in production?
We do this work directly alongside engineering teams — architecture review, migration, and hands-on enablement.