Database Migrations at Scale Zero Downtime

Khimananda Oli 9 min read Database
Database Migrations at Scale Zero Downtime

By Khimananda Oli | Last reviewed: August 2026

Running database migrations at scale zero downtime requires decoupling schema changes from application deployments so neither blocks the other during peak traffic. Most outages I investigate stem from teams running blocking ALTER TABLE statements directly against production primaries or assuming their ORM handles concurrency safely. The solution is adopting an expand-contract workflow combined with online schema change tools that keep tables available while restructuring data in the background.

How do you implement the expand-contract pattern for database migrations at scale zero downtime?

The expand-contract pattern (also called parallel change) is the foundational technique for safe schema evolution in high-traffic environments. Instead of modifying a column in place—which locks the table and breaks running code—you introduce the new structure alongside the old one, migrate data gradually, and only remove the legacy structure after full verification. This approach ensures your application remains functional throughout every phase of the migration.

1. ExpandAdd new columnNULL / Default OK2. Dual WriteApp writes both colsRead from OLD3. BackfillBatch copy OLD→NEWVerify checksums4. ContractRead/Write NEW onlyDrop OLD columnTimeline & Safety ChecksPhase 1→2: Deploy app code AFTER migration adds column. No lock contention.Phase 2→3: Backfill runs as separate job. Throttle to <10% primary CPU.Phase 3→4: Verify row counts match. Run SELECT COUNT(*) on both columns.Phase 4 Cleanup: Drop old column in NEXT release cycle, not same deploy.Critical: Never combine expand + contract in single migration file.
Expand-contract pattern phases for database migrations at scale zero downtime: each phase deploys independently to prevent locking and enable rollback.

Step-by-step expand phase implementation

The expand phase must be completely non-blocking. In PostgreSQL, this means avoiding NOT NULL constraints without defaults and avoiding volatile default expressions that force a full table rewrite. For PostgreSQL administration essentials, always add columns as nullable first:

-- SAFE: Adds column instantly, no table lock beyond catalog update
ALTER TABLE orders ADD COLUMN total_cents BIGINT;

-- UNSAFE on large tables: Forces full rewrite in PG <11, still acquires AccessExclusiveLock
ALTER TABLE orders ADD COLUMN total_cents BIGINT NOT NULL DEFAULT 0;

In MySQL 8.0+, adding a nullable column is an instant metadata-only operation. Adding a NOT NULL DEFAULT column is also instant for most types, but adding a foreign key constraint or modifying an indexed column still requires a table rebuild. Always check INFORMATION_SCHEMA.INNODB_TRX before running DDL to ensure no long-running transactions will block your lock acquisition.

Dual-write application logic

Your application must write to both the old and new columns during the transition period. This is typically handled at the model or repository layer. The read path continues using the old column until backfill completes and verification passes. Here is a practical pattern:

  • On INSERT/UPDATE: Set both total (old decimal) and total_cents (new integer)
  • On READ: Continue selecting total; ignore total_cents until cutover
  • Add a feature flag to toggle read source without redeployment
  • Log discrepancies between old and new values during dual-write for audit

This dual-write period typically lasts days to weeks depending on table size and traffic. Do not rush to the next phase. I have seen teams skip verification and discover silent data corruption months later during compliance audits.

Which online schema change tools prevent locking during database migrations at scale zero downtime?

Even with expand-contract, some operations cannot be decomposed into safe additive steps. Renaming columns, changing primary keys, or adding indexes to massive tables require specialized tools that perform the work without holding exclusive locks. These tools create shadow tables, copy data incrementally, and swap atomically.

ToolDatabaseMechanismBest ForLimitations
gh-ostMySQLTriggerless binlog streamingTB-scale tables, high-write workloadsNo FK support, requires binlog ROW format
pt-online-schema-changeMySQLTriggers + chunked copyTables with FKs, complex renamesTrigger overhead ~30-50% write amplification
pgrollPostgreSQLVersioned schema + expand-contract automationAutomated expand-contract workflowsNewer tool, smaller community than gh-ost
pg_repackPostgreSQLShadow table + log table replayBloat removal, index rebuildsRequires brief exclusive lock at swap
Native CONCURRENTLYPostgreSQLBuilt-in index/constraint creationAdding indexes, unique constraintsLimited to specific operations only

Using gh-ost for triggerless MySQL migrations

gh-ost is my default choice for MySQL tables over 50GB because it avoids triggers entirely. It reads the binary log to capture changes during copy, which eliminates the write amplification that plagues pt-osc under heavy load. A typical invocation for adding an index without downtime:

gh-ost \
  --host="db-primary.internal" \
  --database="ecommerce" \
  --table="orders" \
  --alter="ADD INDEX idx_created_at (created_at)" \
  --allow-on-master \
  --max-load=Threads_running=25 \
  --critical-load=Threads_running=100 \
  --chunk-size=1000 \
  --execute

The --max-load and --critical-load flags are non-negotiable in production. They throttle or pause the migration when the primary is busy, ensuring customer-facing queries never starve. Always test these thresholds in staging with realistic write patterns before production execution.

PostgreSQL concurrent index creation

For PostgreSQL, many operations now have native concurrent alternatives that eliminate the need for external tools. Creating an index without blocking writes has been stable since version 9.6:

-- Does NOT block INSERT/UPDATE/DELETE during build
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders (customer_id);

-- Validate partial or corrupted indexes before making visible
REINDEX INDEX CONCURRENTLY idx_orders_customer_id;

Note that CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Your migration framework must handle this as a separate statement outside the usual transaction wrapper. Tools like Flyway and Liquibase have specific configurations for this; failing to set them results in silent fallback to blocking mode.

How do you safely backfill data during database migrations at scale zero downtime?

Backfilling is where most migrations fail silently. Copying millions of rows in a single transaction exhausts undo logs, bloats replication lag, and risks hours of lost work if interrupted. Safe backfilling requires batching, idempotency, and continuous verification.

Source Table100M rowsBatch Worker1000 rows/batchWHERE id > last_idTarget ColumnIdempotent UPDATECheckpoint Storelast_processed_idResume on failureThrottling & Safety Rules• Sleep 100ms between batches to yield to OLTP workload• Monitor replication lag; pause if >5 seconds behind primary• Batch size adaptive: reduce by 50% if batch duration >2s• Log progress every 10K rows for observability and audit trailCommon Backfill Anti-Patterns✗ Single UPDATE without WHERE clause → locks entire table✗ OFFSET-based pagination → degrades O(n²) on large tables✗ No checkpoint → restart from zero after 8-hour failure✗ Running during peak hours without throttling → customer impact
Batched backfill architecture with checkpointing and adaptive throttling prevents replication lag and enables resumable database migrations at scale zero downtime.

Keyset pagination over OFFSET

Never use OFFSET for backfill queries on tables exceeding 100K rows. Offset performance degrades quadratically because the database must scan and discard all preceding rows. Keyset pagination using the primary key is consistently O(1):

-- BAD: Slows to crawl after 1M rows
UPDATE orders SET total_cents = (total * 100)::BIGINT 
WHERE id IN (SELECT id FROM orders ORDER BY id LIMIT 1000 OFFSET 5000000);

-- GOOD: Constant time regardless of position
UPDATE orders SET total_cents = (total * 100)::BIGINT
WHERE id > :last_processed_id
ORDER BY id ASC
LIMIT 1000;

Store :last_processed_id in a dedicated tracking table or Redis key. This makes the backfill resumable after crashes, network partitions, or planned maintenance windows. Without checkpoints, a failure at row 9 million of 10 million means restarting from zero.

Throttling and replication awareness

Backfill jobs must be polite tenants. Unthrottled updates saturate disk I/O, inflate WAL/binlog volume, and cause replication lag that breaks read replicas and CDC pipelines. Implement adaptive throttling:

  1. Measure batch execution time; target under 500ms per batch
  2. Query pg_stat_replication.lag (PostgreSQL) or SHOW REPLICA STATUS (MySQL) before each batch
  3. If lag exceeds threshold (typically 2-5 seconds), sleep exponentially up to 30 seconds
  4. Reduce batch size dynamically if latency spikes persist
  5. Emit metrics to Prometheus/Grafana for real-time visibility during execution

For teams managing MySQL performance tuning, remember that bulk updates generate significant redo log volume. Ensure your innodb_log_file_size accommodates sustained backfill throughput without frequent checkpoints that stall writes.

How do you verify integrity and roll back database migrations at scale zero downtime safely?

Verification is not optional—it is the gate between dual-write and contract phases. Skipping it turns migrations into gambling. Every backfill must produce cryptographic or statistical proof that source and target data match before any code stops reading the old column.

Checksum and count validation

Run independent verification queries outside the backfill job. Compare row counts first as a fast sanity check, then sample checksums for deeper assurance:

-- Fast count comparison
SELECT 
  COUNT(*) AS total_rows,
  COUNT(total_cents) AS populated_rows,
  COUNT(*) - COUNT(total_cents) AS null_count
FROM orders;

-- Sampled checksum for 1M+ row tables (full hash too expensive)
SELECT 
  COUNT(*) AS sample_size,
  SUM(hashtext(total::text || '|' || total_cents::text)) AS checksum
FROM orders
WHERE id % 100 = 0;  -- 1% sample, deterministic selection

If null_count is non-zero after backfill completion, your dual-write logic has gaps. Investigate application logs for failed writes during the transition window before proceeding to contract.

Safe rollback strategy

Rollback capability must exist at every phase. During expand, dropping the new column is safe because no code reads it. During dual-write, reverting the application deployment restores single-write behavior. During contract, you cannot simply re-add the dropped column—data is gone. This is why contract happens in a subsequent release, only after extended monitoring confirms stability.

Maintain a rollback runbook for each migration phase. For teams following blue-green and canary deployment strategies, tie migration phase transitions to deployment pipeline gates rather than manual coordination. Automated verification checks in CI prevent premature promotion.

Migration StartExpand Phase Complete?YesNoRollback: Drop New ColBackfill Verified?YesNoRollback: Revert AppContract: Switch ReadsMonitor 7 Days Stable?YesNoRe-enable Old ReadDrop Old Column (Next Release)
Decision tree for database migrations at scale zero downtime: each phase has explicit verification gates and defined rollback paths before proceeding.

Conclusion

Executing database migrations at scale zero downtime is a discipline, not a feature. It demands treating schema changes as multi-phase deployments with their own testing, monitoring, and rollback procedures. The expand-contract pattern, combined with appropriate online schema change tools and rigorous backfill verification, eliminates the false choice between progress and availability. Start small: practice the workflow on non-critical tables before attempting it on your core transactional data. If your team needs help designing migration strategies for high-traffic PostgreSQL or MySQL systems, reach out to discuss your specific architecture.

Frequently Asked Questions

The expand and contract pattern ensures safety by adding new columns first, deploying code to write to both old and new fields, backfilling data, then removing deprecated columns in a subsequent release cycle.

Use online schema change tools like gh-ost or pt-online-schema-change which create ghost tables, copy data incrementally, and swap atomically to avoid blocking writes during large table alterations in MySQL or MariaDB environments.

Yes, but disable foreign key checks and use non-blocking DDL. For tables over one million rows, bypass artisan migrate and use external OSC tools to prevent metadata locks that cause application timeouts.

Heavy write amplification from row copying saturates binary logs. Throttle the migration tool's chunk size and sleep intervals to keep slave delay under acceptable thresholds while maintaining forward progress on the primary instance.

Batch updates using primary key ranges with small sleep intervals between chunks. Schedule heavy backfills during low-traffic windows and monitor CPU plus IO wait metrics to dynamically adjust batch sizes based on current load.

Not always, but it simplifies rollbacks when schema changes are incompatible with old code. Maintain backward compatibility first; reserve blue-green for breaking changes where dual-write strategies cannot bridge the version gap safely.

Restore sanitized production snapshots to staging hardware matching production specs. Run the exact migration command while simulating realistic write loads to measure lock contention, replication lag, and total duration accurately.

Track active connections, query latency percentiles, replication seconds behind master, disk IO utilization, and lock wait times. Set automated alerts to pause or abort migrations if any metric breaches predefined safety thresholds.

Phase one adds nullable columns. Phase two deploys app code writing to both old and new columns. Phase three backfills historical data. Phase four switches reads to new columns. Phase five drops old columns after verification.

Yes, metadata locks can queue indefinitely causing cascading failures. Always acquire locks with short timeouts, kill long-running transactions before DDL, and use tools designed to minimize lock scope during structural changes.

Keep the old column intact until full verification completes. If issues arise post-cutover, revert application code to read from the original column immediately. Data remains consistent since dual-write preserved both versions during transition.

Temporarily yes, due to extended storage for ghost tables and increased IO from data copying. Budget twenty to thirty percent extra storage and compute headroom during migration windows to avoid throttling or outages.

Inject credentials via environment variables or secret managers like Vault at runtime. Never store passwords in migration scripts or CI configs. Use IAM authentication where supported to eliminate static credential exposure entirely.

Use pg_repack for table rewrites without exclusive locks, or citus for distributed shard rebalancing. Native CONCURRENTLY options exist for index creation and constraint validation but lack full ALTER TABLE support without extensions.

Skip it for small tables under ten thousand rows, maintenance window tolerant systems, or when complexity outweighs risk. Simple direct ALTER statements finish faster and reduce operational overhead when brief downtime is acceptable.