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.

StepWhat it doesDeployed with
ExpandAdd the new column or table, nullable or with a default, alongside the old oneNew migration, old application code still running
BackfillPopulate the new column from the old oneBackground job or a follow-up migration, no application code change required
CutoverUpdate application code to read and write the new columnNew application code, both columns still present
ContractDrop the old column once nothing reads itFinal 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 resolve as 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 dev against 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 dev is used only locally and in ephemeral preview databases; migrate deploy is 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.