Hirestack
Hirestack
Chapter 12

Migrations at scale

📖 7 min read · 12 sections · May 2026

Why this matters

The single most common Staff-level project: "we need to move data / change a schema / replace a service / split a monolith without downtime." Every meaningfully old system has migrations in its future. Getting them right separates Staff engineers from everyone else.

Migrations are uniquely difficult because:

The discipline of doing migrations safely is mostly: break them into reversible, observable steps; verify each step before proceeding; have a rollback plan for each phase.

The migration playbook

The five-phase model that applies to almost any non-trivial migration:

  1. Phase 1: Build new alongside old. The new system / schema / service exists but isn't used. Writes go only to old.
  2. Phase 2: Dual-write. Writes go to both old and new. Reads still come from old. New has the data but is "trailing" the old (potentially with bugs).
  3. Phase 3: Backfill. Historical data is copied from old to new. Continues during ongoing dual-writes.
  4. Phase 4: Dual-read with verification. Reads come from old (authoritative) but also from new; results compared. Discrepancies logged and investigated. This is the verification gate.
  5. Phase 5: Cutover. Reads switch to new (now authoritative). Old becomes read-only / backup. Wait for confidence period.
  6. Phase 6: Cleanup. Stop writing to old. Eventually delete old.

Each phase is a checkpoint with rollback to the previous phase. You don't commit forward until you're confident.

Online schema changes

The simplest migration: change the schema of a table without downtime.

Naive approach: ALTER TABLE users ADD COLUMN preferences JSONB. For a small table, fine. For a 100GB table:

For risky ALTERs, use shadow-table tools:

These tools are mature. For any ALTER on a table > 10GB or with high write traffic, use them.

Schema evolution patterns

Add a column

  1. Add column nullable (or with default). Fast metadata-only operation on modern PG.
  2. Deploy code that writes the new column (still reads from old).
  3. Backfill the column for existing rows.
  4. Deploy code that reads the new column.
  5. If made NOT NULL, do it only after backfill is complete and verified.

Rename a column

Don't rename directly. Do an expand-contract pattern:

  1. Add new column with new name.
  2. Deploy code that writes to both old and new column.
  3. Backfill new column from old.
  4. Deploy code that reads from new column.
  5. Deploy code that writes only new column.
  6. Drop old column.

This is 6 deploys but each is safe and reversible. A single "RENAME COLUMN" is a coupled deploy nightmare — old code references one name, new code references another; if you roll back the deploy without rolling back the schema, things break.

Change a column type

Similar to rename: add new column with new type, dual-write, backfill, dual-read, swap.

Service migrations — replacing a system

Same five-phase playbook applied to a service.

Example: replacing a legacy order service

  1. Build new alongside old: new service exists, exposing same API. Routes traffic to old.
  2. Dual-write: API gateway sends every write to both old and new services. Reads still go to old.
  3. Backfill: historical orders are copied from old DB to new DB.
  4. Shadow read: for each read, query both old and new; compare responses; log differences. Investigate any discrepancy.
  5. Percentage rollout: 1% of reads use new service as authoritative; 99% still use old. Monitor errors. Gradually increase: 10%, 50%, 100%.
  6. Cutover: 100% reads on new. Old kept warm in case of emergency rollback.
  7. Cleanup: stop dual-writing; remove old service.

Backfilling — the operational concern

Copying millions or billions of rows from one place to another takes time.

Verification — the critical phase

Dual-read with comparison is the gate that catches bugs in the new system. Without it, you discover migration bugs after cutover, in production, with affected users.

What to compare:

Expected differences need explicit handling — sometimes new system has slightly different semantics by design (e.g., new sorting). Don't ignore differences; categorise them.

Rollback planning

Every phase needs an answer to "what if this is bad?"

PhaseRollback
Phase 2 dual-writeStop writing to new. Old continues unaffected.
Phase 3 backfillPause/cancel. Verify old not modified.
Phase 4 dual-readStop reading from new (still doing it for verification, not authoritative). Old unchanged.
Phase 5 cutover (initial %)Reduce % back to 0. Reads return to old.
Phase 5 cutover (100%)Switch reads back to old. New may have writes old doesn't have — reconcile.
Phase 6 cleanupOnce you drop the old, no rollback. Don't drop until weeks of stable operation on new.

Real-world examples

Stripe's online migrations

Stripe has written extensively about their migration framework. Multi-month migrations of foundational data, with continuous verification and graceful rollout. stripe.com/blog/online-migrations is the canonical writeup.

GitHub's MySQL → Vitess

GitHub used logical replication + a phased migration to move from monolithic MySQL to sharded Vitess. Multiple years from start to completion of all tables. github.blog.

Notion's sharding migrations

Notion sharded from 1 to 32 to 96 logical shards. Each transition followed the playbook above. notion.so/blog/sharding-postgres-at-notion.

Common migration pitfalls

Applied scenarios

Scenario: You need to add a foreign key constraint on an existing 500GB table. The simple ALTER would take 6 hours and lock writes. Design the migration.
Use NOT VALID + VALIDATE pattern in Postgres. Step 1: ALTER TABLE ADD CONSTRAINT ... FOREIGN KEY ... NOT VALID — fast, doesn't validate existing rows but enforces on new writes. Step 2: Fix any pre-existing rows that violate the constraint (background job). Step 3: ALTER TABLE VALIDATE CONSTRAINT ... — scans the table but does NOT take a strong lock; concurrent writes continue. Pattern documented in Postgres docs. For MySQL, use gh-ost or pt-online-schema-change which handles this transparently.
Scenario: You're migrating a 200M-row table to a new shard. How long, and what's the rollout?
Backfill rate: depends on source throughput and throttle. Realistic: 1-5K rows/sec sustained. 200M rows / 3K rps = ~18 hours of pure copy time. With throttling and pauses: 2-3 days actual elapsed. Then dual-read for at least 3-5 days. Then percentage rollout: 1% (24h), 10% (24h), 50% (48h), 100% (1 week). Then cleanup after another 2 weeks. Total: 3-4 weeks for the whole migration. Bigger tables: months.

Further reading

Migrations at scale