Test Migration Rollbacks in CI
Apply, roll back, and re-apply every migration against a throwaway database, and fail the build when the down path breaks or drifts.
Task
Prove every schema migration can be rolled back and re-applied without losing objects the application depends on.
When to use this
A migration adds a column and an index, but the down migration drops the column and forgets the index, so a rollback leaves the schema subtly different and the next deploy fails.
Prerequisites
- A migration tool with up and down commands (Flyway, Prisma Migrate, Knex)
- PostgreSQL 13 or newer in Docker or locally
- pg_dump available in CI
Steps
1. Apply every migration to a throwaway database and capture the schema baseline.
createdb app_migrate_test && DATABASE_URL=postgres://localhost/app_migrate_test pnpm migrate:up && pg_dump --schema-only --no-owner app_migrate_test > before.sqlExpected output: The latest version is applied and before.sql contains all tables, indexes, and constraints.
2. Roll the last migration back and re-apply it.
pnpm migrate:down && pnpm migrate:upExpected output: Both commands exit 0 and the migration history ends at the same version it started from.
3. Compare the schema after the cycle with the baseline.
pg_dump --schema-only --no-owner app_migrate_test > after.sql && diff -u before.sql after.sqlExpected output: No structural differences; ownership and comment noise is normalized out of the dump.
4. Run the application against the rolled-back schema to prove the previous version still works.
pnpm migrate:down
DATABASE_URL=postgres://localhost/app_migrate_test pnpm start &
curl -fsS http://localhost:3000/healthz
curl -fsS 'http://localhost:3000/api/orders?limit=1'
pnpm migrate:up
Expected output: Health check and a key read both succeed on version N-1, so a rollback is operationally usable.
5. Run the full cycle per migration in CI, from a fresh database each time.
pnpm test:migrationsExpected output: The job fails on the first migration whose down path errors or produces schema drift.
Troubleshooting
Problem
The tool has no down migration for a change
Solution
Write one, or mark the migration irreversible explicitly. Irreversible changes need a restore drill and a documented rollback plan instead.
Problem
The diff reports sequence values or ownership changes
Solution
Normalize the dump: exclude owners, comments, and sequence last_value, and compare only structure and constraints.
Problem
Rollback succeeds but data is gone
Solution
Dropping a column or table is destructive. Use expand-and-contract migrations, or require a verified backup before any down migration runs.
Limitations
- Some migrations are inherently irreversible, especially data transformations and dropped columns.
- Schema diffing needs normalization; a naive diff produces false failures and gets ignored.
- It cannot prove that data survives a rollback; destructive changes need separate backup verification.
Rollbacks are code too
Most teams test the forward path and discover the down path during an incident. A rollback that errors, leaves an orphan index, or drops a column the previous release still reads turns a routine deploy problem into an outage. The only way to trust a rollback is to execute it regularly, against the same schema and the same data shape as production.
Test the whole cycle, not just down
The meaningful property is round-trip stability: apply all migrations, roll back one step, re-apply, and end with the same schema. A down migration that runs without error but forgets to recreate an index passes a naive check and fails the next query plan. Capture pg_dump --schema-only before and after, normalize ownership and comments, and diff. Any structural difference is a bug in the migration.
pnpm migrate:down && pnpm migrate:up
pg_dump --schema-only --no-owner app_migrate_test > after.sql
diff -u before.sql after.sql
Prove the old release still runs
A schema can be structurally identical and still break the previous application version if the down migration removed a column that version reads. Run the N-1 container against the rolled-back schema and hit a health endpoint plus one read path. This catches the common mistake of rolling back the schema but not the code, or vice versa.
Automate per migration
Iterating from the newest migration down to the oldest finds the exact step that is broken, instead of a generic failure. Run each step in a fresh database in CI, and stop at the first error. Keep the job fast by using a template database or a container that starts empty; the whole matrix usually fits in a few minutes.
Handle the irreversible cases
Data transformations, dropped columns, and enum value removals cannot be undone by SQL alone. Mark them irreversible in the migration file, require a backup and a restore drill, and prefer expand-and-contract: add the new structure, dual-write, backfill, switch reads, then remove the old structure in a later release. That sequence makes rollback a code change rather than a data recovery event.