Most parts of a deployment are reversible. A bad container image can be rolled back in seconds. A schema migration is different: it changes shared, stateful data, and a mistake there can be slow, destructive, or simply impossible to undo cleanly. The goal of this part is not to make migrations risk-free—no discipline achieves that—but to make them boring, checkpointed, and recoverable.
Why migrations are the highest-risk part of a deploy
A migration that runs cleanly against an empty local database can still fail against a production table with real row counts, real constraints already violated by legacy data, or real concurrent traffic. Locking behavior that is invisible on a development machine can hold a production table for minutes under load. The risk is not that migrations are hard to write. It is that the environment where they are least tested—production—is the one where a mistake costs the most.
The previous part in this series already runs every migration once, automatically, against a fresh database on every pull request. That is the first and cheapest checkpoint. This part extends the same discipline through preview, staging, and production.
Prisma's migration model in one paragraph
Prisma tracks schema changes as an ordered set of SQL migration files under prisma/migrations/, each generated from a diff against schema.prisma. prisma migrate dev is a local, interactive command: it generates a new migration file, applies it, and can reset the development database when history diverges. prisma migrate deploy is the non-interactive counterpart meant for CI and production: it applies any pending migrations in order and does nothing else—no prompts, no resets, no destructive shortcuts.
# Local development: generate and apply a new migration npx prisma migrate dev --name add_subscription_status # CI, preview, staging, production: apply pending migrations only npx prisma migrate deploy
Keeping these two commands in their separate lanes is itself a safety rule. migrate dev should never run outside a developer's own machine or an ephemeral preview database; migrate deploy should be the only command that ever touches staging or production data.
The four-stage promotion path
A migration should pass through four environments before it is considered safe, each one closer to production conditions than the last:
Staging matters here specifically because it is the first environment with data that resembles production in shape and volume, even if it is not production itself. A migration that ran fine against a preview database with a dozen seeded rows can behave very differently against a staging database that mirrors real table sizes. Treat a staging failure as valuable information caught early, not as an obstacle to route around.
Backward-compatible migrations: expand and contract
The single most useful habit in this discipline is separating a schema change into an expand step and a contract step, rather than changing and removing in one motion. Renaming a column directly, for example, breaks the moment old application code and new application code run side by side during a rolling deploy—which they always do, even briefly.
| Step | What it does | Deployed with |
|---|---|---|
| Expand | Add the new column or table, nullable or with a default, alongside the old one | New migration, old application code still running |
| Backfill | Populate the new column from the old one | Background job or a follow-up migration, no application code change required |
| Cutover | Update application code to read and write the new column | New application code, both columns still present |
| Contract | Drop the old column once nothing reads it | Final migration, only after the cutover has been live and verified |
This takes more steps than a direct rename, and that is the point. Each step is small enough to reason about and to roll back independently. A direct rename saves time until the one deploy where old and new code overlap for even a few seconds, and every request in that window fails.
Backup checkpoints before anything irreversible
Not every migration needs a fresh backup—routine, additive changes running through a database with automated point-in-time recovery already carry reasonable protection. But any migration that drops a column, drops a table, or rewrites data in place deserves a deliberate, verified checkpoint immediately before it runs, treated as a distinct release step rather than an assumption that "backups are running somewhere."
# Verified, on-demand checkpoint before a destructive migration flyctl postgres backup create --app my-app-db flyctl postgres backup list --app my-app-db
The habit worth building is not "we have backups." It is "we confirmed a specific, restorable checkpoint exists immediately before this specific irreversible step." The difference matters the day it is needed.
Recovering when a migration fails midway
Prisma applies each migration inside a transaction where the underlying database supports it, which means most failures roll back cleanly and leave the schema exactly as it was. The harder case is a migration that partially succeeds against a database that does not support transactional DDL for the specific operation involved, or one that times out mid-run against a large table. For that case, the recovery plan should exist before the migration runs, not get improvised afterward:
- Know, in advance, whether the specific operation is transactional for the database engine in use.
- Have the exact restore command for the checkpoint tested, not just documented.
- Treat
prisma migrate resolveas a deliberate, understood tool for marking a migration applied or rolled back after manual intervention—not a command reached for under pressure without knowing what it changes. - Decide who has authority to run a production restore before an incident, not during one.
Common mistakes
- Renaming or dropping a column in a single migration deployed alongside new application code, guaranteeing a window of failed requests.
- Running
prisma migrate devagainst staging or production out of habit, which can trigger an unintended reset. - Treating "we have automated backups" as equivalent to "we verified a restorable checkpoint before this specific change."
- Letting a migration accumulate a long, slow table lock in production because it was never tested against realistic row counts in staging.
- Skipping the staging stage under deadline pressure, turning production into the first real test.
A one-sentence test before shipping
If a migration cannot be described in one sentence as "safe to run while both old and new application code are briefly live," it is not ready to deploy. That single question catches most of the failures above before they reach staging.
Practical checklist
-
migrate devis used only locally and in ephemeral preview databases;migrate deployis the only command used from staging onward. - Column and table removals follow an expand-backfill-cutover-contract sequence across separate deploys.
- A verified, restorable backup checkpoint exists immediately before any destructive migration.
- Staging runs the same migration, on data shaped like production, before production ever sees it.
- The recovery plan for a partially failed migration is written down before it is needed.
- Authority to run a production restore is decided in advance, not during an incident.
Previous: Pull Request Preview Environments: Review the Actual Change
Next: Secrets, Environments, and Deployment Security in GitHub Actions. With migrations covered, the series turns to protecting the credentials that let CI reach staging, production, and the database at all.

