Migration Rollback
Make every migration reversible — the safety net that lets you migrate aggressively and recover from failure.
Why
An irreversible migration is a one-way door: if the new schema breaks the app, your only option is restore-from-backup, which loses every write since the migration. Reversible migrations (expand-contract with a real DOWN) convert this to a two-way door: if the new code fails on the new schema, you roll back the code and the old code still works (because expand-contract keeps the old schema compatible). The key is that the DB migration and the code deploy are separate steps, and the migration is always expand-first (additive, non-breaking) so the old code keeps working. Rehearsing the rollback — running DOWN in staging — is what makes it work at 3am, because you've proven the down migration is real and not a no-op.
When
Before every migration deploy. Rehearse the rollback in staging as part of the migration PR's definition of done.
How
1. Write a real DOWN migration (not a no-op) that reverses the UP — test it: UP, DOWN, UP must all succeed 2. Use expand-contract: UP is additive (add column/table), so old code still works on the new schema 3. Deploy the migration (UP) first; the old code keeps running on the expanded schema 4. Deploy the new code; if it fails, roll back the CODE (not the DB) — old code works on the new schema 5. Only if the schema itself is broken (e.g., a bad constraint) do you run DOWN — and the old code still works 6. Rehearse in staging: run UP, deploy new code, run DOWN, deploy old code — confirm the app works at every step 7. Keep the old code deployable for the contract phase (don't delete it until after the final contract migration)
Example
# Reversible migration (Knex) — add NOT NULL with a default, then backfill
exports.up = async (knex) => {
// expand: add nullable column (old code ignores it)
await knex.schema.alterTable('users', t => t.text('display_name').nullable());
// backfill
await knex.raw('UPDATE users SET display_name = name');
// contract: make NOT NULL (after backfill, safe)
await knex.schema.alterTable('users', t => t.text('display_name').notNullable());
};
exports.down = async (knex) => {
// reverse: make nullable, then drop (old code works without it)
await knex.schema.alterTable('users', t => t.text('display_name').nullable());
await knex.schema.alterTable('users', t => t.dropColumn('display_name'));
};
# Rehearse in staging (CI job on the migration PR):
# 1. npx knex migrate:latest # UP
# 2. deploy new code; run smoke tests
# 3. npx knex migrate:rollback # DOWN
# 4. deploy old code; run smoke tests # must still pass
# If any step fails, the migration doesn't ship.
# Production rollback (if new code fails on new schema):
# kubectl rollout undo deployment/app # revert CODE only
# (DB stays at UP; old code works on expanded schema)
# Only if the schema is broken:
# npx knex migrate:rollback # DOWN the DBCommon Problems
- ⚠A no-op DOWN migration — you think you can roll back, but at 3am you discover the down does nothing and the schema is stuck.
- ⚠Combining the DB migration and code deploy in one step — if it fails you can't tell which to roll back, and they need different rollback commands.
- ⚠Never rehearsing the rollback — the DOWN has a bug (references a dropped table) that only surfaces when you need it most.
Best Practices
- ✓Always write a real DOWN migration and test UP→DOWN→UP in staging — a no-op down is a lie that fails at 3am.
- ✓Deploy the DB migration (expand) and the code separately — roll back code first; only roll back the DB if the schema itself is broken.
- ✓Make rollback rehearsal a CI gate: UP, deploy, DOWN, redeploy-old — if the app doesn't work at every step, the migration doesn't ship.