Safe Database Migrations in a Pipeline
Schema changes are the riskiest part of a deploy because old and new code run at once.
During any zero-downtime deploy, old and new application versions briefly share the database. A migration that the old code cannot tolerate causes outages. This lesson teaches the expand-and-contract pattern and how to sequence migrations safely in a pipeline.
Why migrations break deploys
In a rolling or blue-green deploy, both versions hit the same schema. If you rename a column, the version that does not know the new name breaks. The fix is to never make a single breaking change - make a sequence of backward-compatible ones.
Expand and contract
- Expand: add the new column or table; keep the old one. Both versions work.
- Migrate: backfill data and dual-write so old and new stay in sync.
- Switch: deploy code that reads and writes the new shape.
- Contract: once nothing uses the old shape, drop it in a later release.
Run migrations as a pipeline step
Apply the expand migration before deploying new code, and the contract migration only after the new code is fully rolled out and stable:
jobs:
migrate-expand:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run migrate:up # additive, backward-compatible only
deploy:
needs: migrate-expand
runs-on: ubuntu-latest
steps:
- run: ./deploy.shGuard the destructive steps
Never drop columns or tables in the same release that stops using them. Hold destructive migrations one or more releases behind, so you can roll the application back without hitting a missing column. Keep migrations forward-only and tested against a production-like dataset.
Key takeaways
- Old and new code share the database mid-deploy, so changes must be backward compatible.
- Expand-and-contract splits a breaking change into safe additive steps.
- Delay destructive migrations a release behind so rollback stays possible.