This document describes the strategy for performing database schema migrations without downtime, using the Expand-Migrate-Contract pattern, dual-write phases, and proper rollback procedures.
Zero-downtime migrations follow three phases:
Add the new schema elements alongside existing ones. The application continues to use the old schema during this phase.
- Add new columns (nullable or with defaults).
- Create new indexes (concurrently where possible).
- Add new tables.
The application is updated to dual-write to both old and new columns, but still reads from the old schema. No existing data is modified.
Backfill and transition data to the new schema:
- Backfill new columns with computed values.
- Validate data consistency between old and new columns.
- Deploy application update that reads from the new schema (while still dual-writing).
- Monitor for errors and roll back if needed.
Remove the old schema elements after the new schema is fully validated:
- Remove dual-write logic from the application.
- Drop old columns, indexes, or tables.
- Run a final validation pass.
During dual-write every write operation writes to both the old and new schema:
// Example: dual-write for a column rename
await entityManager.update(Table, id, {
old_column: value, // keep writing old
new_column: value, // write new too
});Reads use a feature flag or environment variable to toggle between old and new schema. This allows instant rollback by flipping the toggle.
The CI pipeline (db-migration-safety.yml) enforces:
| Check | Rule |
|---|---|
| Column drops | Blocked — must use Expand-Migrate-Contract |
| Column type changes | Blocked — must add new column instead |
| NOT NULL without DEFAULT | Blocked — breaks existing rows |
| Table drops | Blocked — must use soft-delete first |
| Renames | Blocked — breaks running application references |
| DEFAULT drops | Blocked — may cause insert failures |
If the migration safety check fails in CI, deployment is gated. If a migration fails at runtime:
- Flip the read toggle back to the old schema.
- Run
infrastructure/scripts/rollback-migrations.sh. - Verify the application is healthy using old schema reads.
- Investigate and fix the migration in a new PR.
If automated rollback is unavailable:
- Revert the application deploy to the previous version.
- Run
npm run typeorm:rollbackfrom the previous release tag. - Verify database state with
check-migration-safety.sh --rollback.
- One logical change per migration — smaller migrations are easier to review and roll back.
- Always provide a down migration — every
upmust be reversible. - Test migrations against a copy of production data before deploying.
- Run migrations outside of peak traffic hours.
- Monitor replication lag during large backfill operations.
- Keep migrations idempotent — use
IF NOT EXISTS/IF EXISTSclauses.