Prisma Migrations in Production: The Zero-Downtime Playbook

Technical PM + Software Engineer
Production schema changes feel scary for a good reason. A bad migration is one of the few mistakes that can break an otherwise healthy app instantly: deploy succeeds, app boots, then writes fail, reads slow down, or a lock takes out the table everyone depends on.
The fix is not to avoid schema changes. The fix is to stop treating migrations like a single command you run at the end of a feature. In production, the safest Prisma workflow is a staged process: design for compatibility, generate intentionally, review SQL before it runs, separate expand from contract, and make rollback decisions before you ship.
The mental model: expand, migrate, contract
The biggest mistake teams make with Prisma Migrate is assuming every schema change should happen in one deploy. That works in development. It is much riskier in production.
A safer model is:
- Expand the schema in a backward-compatible way.
- Ship application code that can read and write both old and new shapes.
- Backfill data if needed.
- Flip traffic or application logic to the new path.
- Contract the old schema only after production has been stable.
This pattern matters because your database and your application are not updated atomically. There is always a window where old app code, new app code, background jobs, and delayed workers may all be touching the same tables.
If your migration assumes a clean instant cutover, you are designing for a world that production does not give you.
Where Prisma helps and where you still need judgment
Prisma Migrate is excellent at giving teams a repeatable migration history. It keeps schema intent in version control, generates migration files, and provides a path for development, CI, and production.
What it does not do for you is decide whether a generated migration is operationally safe.
That part still needs engineering judgment.
A generated migration might be logically correct and still be a bad production migration because it:
- rewrites a large table in one shot
- adds a required column before the application can populate it
- drops a column that old workers still read
- creates an index in a way that blocks writes
- bundles data migration and schema migration into one risky step
So the right Prisma habit is simple: trust Prisma to manage migration history, but never stop reading the SQL.
A concrete example: renaming a column safely
Suppose you have a users table with a name column, and you want to move to a clearer split of first_name and last_name.
The unsafe version looks appealing because it is short:
ALTER TABLE users DROP COLUMN name;
ALTER TABLE users ADD COLUMN first_name TEXT NOT NULL;
ALTER TABLE users ADD COLUMN last_name TEXT NOT NULL;
That is a production footgun. You have dropped live data and introduced two required columns the old app does not know how to populate.
The safer rollout is staged.
Step 1: expand
Add nullable columns first.
model User {
id String @id @default(cuid())
name String?
firstName String? @map("first_name")
lastName String? @map("last_name")
}
Generate the migration, then inspect the SQL.
npx prisma migrate dev --name add-first-and-last-name
The resulting SQL should be boring:
ALTER TABLE "users" ADD COLUMN "first_name" TEXT;
ALTER TABLE "users" ADD COLUMN "last_name" TEXT;
That is what you want at this stage: additive, reversible, and compatible with the current app.
Step 2: ship dual-write code
Update the application so new writes populate both the old and new representation until backfill is complete.
await prisma.user.update({
where: { id: userId },
data: {
name: fullName,
firstName,
lastName,
},
})
If reads still depend on the old field in some places, that is fine temporarily. The goal here is compatibility, not purity.
Step 3: backfill existing rows
Do not try to cram a large data backfill into the schema migration itself. Run it as an explicit script or job so you can observe progress and retry safely.
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function backfillNames() {
const users = await prisma.user.findMany({
where: {
name: { not: null },
OR: [{ firstName: null }, { lastName: null }],
},
take: 500,
})
for (const user of users) {
const [firstName = '', ...rest] = (user.name ?? '').trim().split(/\s+/)
const lastName = rest.join(' ') || null
await prisma.user.update({
where: { id: user.id },
data: { firstName, lastName },
})
}
}
backfillNames()
.catch(console.error)
.finally(() => prisma.$disconnect())
For larger tables, batch this work, checkpoint progress, and run it in a worker or script you can resume.
Step 4: switch reads
Once backfill is complete and new writes are dual-writing correctly, update reads to use firstName and lastName.
Step 5: contract later
Only after production has been stable should you remove the old name field and tighten nullability.
That might be a second or third deploy, not the same one.
Why --create-only is worth using more often
One of the best production habits with Prisma is generating the migration without immediately applying it so you can review and edit the SQL intentionally.
npx prisma migrate dev --create-only --name add-user-name-split
This gives you a migration file without executing it against the database right away.
That matters when you want to:
- review lock-heavy operations
- split one conceptual change into multiple real deployment steps
- replace generated SQL with a safer variant
- document the operational plan in the migration itself
For development-only projects, this can feel like extra ceremony. For production systems, it is usually the difference between "migration generated" and "migration is actually safe to run."
The shadow database is a guardrail, not a production guarantee
Prisma's shadow database helps validate migrations during development by replaying migration history in a clean environment. That catches a lot of issues early.
It does not tell you whether the migration is safe against production data volume, hot tables, lock behavior, or deployment sequencing.
That is why a migration can pass locally and still be a bad production rollout.
Use the shadow database as a schema consistency check. Do not mistake it for an operational readiness check.
Indexes deserve special treatment
Indexes are one of the most common places where teams get surprised in production.
A generated migration that adds an index may be technically correct but operationally dangerous if it blocks writes on a busy table. In PostgreSQL, you may need a concurrent index build instead of the default behavior.
That is exactly the kind of case where reviewing and editing migration SQL matters.
For example, on a heavily used table, you may prefer something closer to:
CREATE INDEX CONCURRENTLY "users_email_idx" ON "users" ("email");
That comes with operational tradeoffs and transaction restrictions, but it is often the safer production choice.
The broader lesson is this: indexes are infrastructure changes, not just schema decorations.
Required columns are usually a two-step change
Adding a required column to a populated table is another classic outage path.
The safer rollout is:
- add the column as nullable or with a safe default
- backfill existing rows
- update application writes so new rows always populate it
- enforce
NOT NULLonly after the data is ready
That means this:
model Invoice {
id String @id @default(cuid())
customerId String?
}
comes before this:
model Invoice {
id String @id @default(cuid())
customerId String
}
When teams skip the intermediate state, they usually push complexity into the riskiest part of the release.
A production deployment checklist for Prisma migrations
When I want a migration to be boring in production, this is the checklist I use.
Before merge
- Read the generated SQL, not just the Prisma schema diff.
- Ask whether the change is additive, destructive, or data-touching.
- Identify whether the app needs a dual-read or dual-write phase.
- Decide whether the change should be one deploy or multiple.
- Confirm rollback strategy before approval.
Before production apply
- Verify recent backups or recovery posture.
- Estimate table size and traffic sensitivity.
- Check whether indexes or constraints may lock writes.
- Confirm background workers are compatible with both old and new shapes.
- Make sure observability is in place for write failures, lock time, and query latency.
After apply
- Watch error rate, latency, and database load.
- Verify new writes populate the expected columns.
- Check backfill progress explicitly.
- Delay destructive follow-up migrations until the new shape is proven stable.
That may sound heavy, but it is still far cheaper than learning migration safety during an incident.
Rollback is not always "run the opposite SQL"
Teams often ask for a rollback plan as if every migration can simply be reversed. Sometimes it can. Often the safer rollback is actually one of these:
- roll application code back while leaving the additive schema in place
- stop the backfill job and stabilize before continuing
- re-enable old reads while dual-write remains on
- postpone the contract step indefinitely until the issue is understood
In other words, the best rollback is often architectural compatibility, not a dramatic down migration.
That is one more reason the expand-migrate-contract model works so well. It gives you room to recover without forcing the database into a second risky operation under pressure.
The workflow I trust in real production systems
If I were giving a team one production Prisma workflow to standardize on, it would be this:
- Model the schema change in Prisma.
- Generate the migration with
--create-onlywhen the change is non-trivial. - Review and edit SQL for operational safety.
- Prefer additive changes first.
- Ship compatibility code before destructive changes.
- Run backfills separately with observability.
- Remove old columns and constraints only after stability is proven.
That workflow is not flashy. It is just reliable.
And that is the point. Production migrations should feel boring because the thinking happened before the command ran.
Final takeaway
Prisma Migrate is strong tooling, but safe production rollouts come from the workflow around it.
If you treat every schema change like a staged release instead of a single command, you make better decisions about nullability, indexes, backfills, rollbacks, and sequencing. You also make life much easier for the version of you who has to watch the deployment in real time.
The goal is not to never feel nervous about production migrations. The goal is to replace vague anxiety with a repeatable playbook.
That is when migrations stop feeling like a gamble and start feeling like engineering.