Skip to content
Back to Maintenance & LTS

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 DB

Common 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.