Which Command Runs Where: migrate dev, migrate deploy, db push
A migration file is a timestamped SQL artifact you commit like code: prisma/migrations/20250304101500_add_order_items/migration.sql. Because every environment replays the same files in the same order, migration files are what make dev, staging, and production converge on the same shape — without them, each database drifts into a private dialect of your schema.
The three commands have non-overlapping jobs:
| Command | Writes files | Applies changes | Resets database | Where it belongs |
|---|---|---|---|---|
migrate dev |
yes | yes | yes, on detected drift | local + preview |
migrate deploy |
no | pending only, in order | never | CI / production |
db push |
no | direct DDL, no history | no | throwaway prototypes |
migrate dev is a development tool: it creates the migration, applies it, may drop and rebuild your local schema when it detects hand-edited drift, and regenerates the client. migrate deploy is a deployment tool: it reads _prisma_migrations, applies anything pending, and stops. It never resets, never regenerates, never prompts — the properties you need when a running system is watching.
Production runs before traffic does:
# GitHub Actions — migrate, then promote the new build
- name: Apply migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}
- name: Deploy app
run: ./deploy.sh
Running it in CI rather than at container boot keeps migrations single-writer: twenty instances racing to apply the same DDL is a deadlock you introduce deliberately. A failure lands in _prisma_migrations with P3009: migrate found failed migrations in the migration database; fix the SQL, then record the decision with prisma migrate resolve --rolled-back 20250304101500_add_order_items.
An existing, unmanaged database needs a baseline — tell Prisma which history it has already "run" without running it:
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script \
> prisma/migrations/0_init/migration.sql
npx prisma migrate deploy
npx prisma migrate resolve --applied 0_init
Otherwise the first deploy tries to CREATE TABLE "User" and fails with a duplicate-object error.
The sequencing rule that governs all of this: migrations must be backward compatible with the code that is currently live. Deploys are not atomic — old instances keep serving while the new schema lands — so a migration may add, but the old code must not notice. Keep every query from Querying with Prisma Client working against both the pre- and post-migration schema, and the habit starts here: review migration SQL in the pull request with the same skepticism as application code.
What _prisma_migrations actually stores is worth knowing when you debug a stuck pipeline: one row per migration with its started_at, finished_at, logs, and rolled_back_at. finished_at null means it is running or died mid-flight — that is the row P3009 points at. The table is also the contract for "applied exactly once": two concurrent migrate deploy runs race to insert the row, and the loser stops, which is why one writer in CI beats any amount of locking discipline elsewhere.
Seed data follows the same single-source rule. Declare it once:
{ "prisma": { "seed": "tsx prisma/seed.ts" } } // in package.json
npx prisma db seed # after migrate deploy / migrate reset
Write seeds to be idempotent (upsert, or deleteMany then create) so re-running them in staging never multiplies rows. One migration per pull request keeps the replay order obvious and the blast radius of a bad file small.
Renaming a Column Without Taking the Site Down
The instinctive change is one deploy: rename User.name to User.fullName, update the model, ship. During the rollout window — new database shape, old code still serving — every request from an old instance fails with:
PrismaClientKnownRequestError:
Invalid `prisma.user.findMany()` invocation
P2022: Column "name" does not exist on table "User"
Thirty seconds of 500s on your profile page, longer if the rollout stalls. The pattern that avoids it is expand and contract, split across at least two releases:
Expand — add the new column alongside the old one.
fullName String?is nullable, so theALTER TABLE ... ADD COLUMNis metadata-only in Postgres 11+ and instant even on large tables. Ship code that writes both columns and reads the new one with a fallback to the old.Migrate data — backfill in batches so you never hold a lock for minutes:
UPDATE "User" SET "fullName" = "name" WHERE "fullName" IS NULL AND id IN (SELECT id FROM "User" WHERE "fullName" IS NULL LIMIT 1000); -- repeat until 0 rows; verify counts before proceedingSwitch reads — release the code that reads
fullNameonly, and assert the backfill is complete (a count check in a script, not a hope).Contract — a later release drops
nameand removes it from the schema. Only now is the change destructive, and only now is old code gone.
Locking is why this is not ceremony. ALTER TABLE "User" ADD COLUMN ... NOT NULL without a default rewrites the table under an ACCESS EXCLUSIVE lock, which queues behind every open query and lets a two-minute migration become a ten-minute outage. CREATE INDEX takes a lock that blocks writes for its full build. Prisma runs each migration's SQL inside a transaction, and CREATE INDEX CONCURRENTLY cannot run in a transaction — so on a large table, index creation is a deliberate, monitored step outside the normal migration flow rather than a line you paste into migration.sql.
Destructive changes deserve the explicit warning Prisma gives you: P3001: You are about to drop the column "name" on the table "User". All data in it will be lost. In development migrate dev asks for confirmation (or --accept-data-loss); treat the same message in any shared environment as a signal that the contract phase is scheduled, reviewed, and backed up. The queries you wrote against those columns — the selects and includes from Querying with Prisma Client — are exactly what break the moment the column disappears, so the drop ships only after no released code names it.
Constraints get the same phased treatment. Adding NOT NULL to a column with existing rows requires a full-table verification under a lock; Postgres offers a lock-friendly two-step:
ALTER TABLE "User" ADD COLUMN "fullName" TEXT; -- instant
-- ...batched backfill...
ALTER TABLE "User" ALTER COLUMN "fullName" SET NOT NULL; -- scans; do it off-peak
ALTER TABLE "User" ADD CONSTRAINT email_format CHECK (email ~ '@')
NOT VALID; -- takes the lock, checks no rows
ALTER TABLE "User" VALIDATE CONSTRAINT email_format; -- verifies existing rows under a
-- weaker lock that still allows writes
NOT VALID splits "apply the rule to future writes" from "audit history", which is exactly the expand-then-verify shape of every risky change. Every one of these statements belongs in a migration file that someone reviewed — the pattern only works if the whole sequence is versioned.
When the Migration Is the Bug: Planning the Rollback Before You Need It
Ask how you'd roll back a bad migration and the honest answer starts with: Prisma migrations are forward-only. migrate deploy has no down direction, no migrate rollback. Reversing means writing a new migration that undoes the old one — plus a restore if data was destroyed. Interviews reward candidates who say this plainly instead of describing a tool that does not exist.
Three failure classes, three different responses:
| Failure | Signature | Response |
|---|---|---|
| SQL dies mid-run | P3009, row in _prisma_migrations marked failed |
fix SQL, migrate resolve --rolled-back <name>, redeploy |
| Migration applied, app misbehaves | errors after deploy, schema is "correct" | revert the code; schema must tolerate both versions |
| Data destroyed | P3001 confirmed, rows gone |
restore from backup / point-in-time recovery |
The middle row is the one that matters in practice, and it dictates policy: keep the schema compatible with both the previous and the current release until you are confident — nullable new columns, no dropped columns, dual reads. Then a code rollback is seconds (deploy.sh re-running the old image) while the database stays exactly where it is. The rollback unit is the deploy, not the migration.
Classify every change before you write it:
| Change | Reversible? | Safe to ship in one release? |
|---|---|---|
ADD COLUMN nullable or defaulted |
trivially | yes |
CREATE INDEX |
yes, DROP INDEX |
yes, but CONCURRENTLY on big tables |
RENAME / DROP COLUMN |
not without data loss | only via expand-contract |
Narrow a type (TEXT → INT) |
no | add a column, backfill, then swap |
SET NOT NULL |
fails if NULLs exist | backfill, verify counts, then constrain |
Data-loss recovery depends on the platform, not on you: Neon, Supabase, and RDS all keep point-in-time history measured in minutes to days, so the drill is "restore to a scratch database, copy the rows back" — worth having run once before an incident, not during one.
A recovery script that also guards against a repeat:
# after fixing a failed migration
npx prisma migrate resolve --rolled-back 20250304101500_add_order_items
npx prisma migrate deploy # re-applies the corrected SQL
npx prisma migrate status # confirms: "Database schema is up to date!"
migrate status in a deploy pipeline answers the question your on-call actually asks — what is the shape of production right now? — before anyone SSHes anywhere. And the transport question comes next: none of these guarantees hold when the process applying them is a short-lived function in HTTP Drivers at the Edge.
Because there is no automatic down direction, author the reversal deliberately. Revert schema.prisma to the previous shape and let Prisma write the undo SQL for you:
npx prisma migrate diff \
--from-migrations prisma/migrations \
--to-schema-datamodel prisma/schema.prisma \
--shadow-database-url $SHADOW_DATABASE_URL --script
Save that as migrations/<ts>_revert_add_order_items/migration.sql and your "rollback" is a normal forward migration — reviewable, recorded, and replayable in every environment, including staging, where you will actually test it.
A pre-deploy checklist keeps the reasoning honest:
- Additive only? If not, is the old code proven compatible until it is gone?
- Backfill batched and counted? How long does the slowest statement hold its lock?
- Does someone know the point-in-time recovery window before they need it?
- Is there an owner for the revert decision, not just for the deploy?
Quiz
1. Where does `prisma migrate deploy` belong, and which behavior defines it?
2. A production database exists but has never used Prisma migrations. How do you baseline it?
3. You rename User.name to User.fullName in one deploy. While old code still serves traffic, what breaks?
4. You put `CREATE INDEX CONCURRENTLY` in a Prisma migration file and it fails. Why?
Flashcards
Question
migrate dev vs migrate deploy vs db push?
Click to reveal answer
Answer
dev: creates SQL files, applies them, may reset on drift - local/preview only. deploy: applies pending files in order, never resets or generates - CI/production. db push: direct DDL, no files - throwaway prototypes. History is what makes environments converge.
Question
What does baselining an existing database mean, and which commands do it?
Click to reveal answer
Answer
Teaching Prisma that pre-existing tables correspond to an initial migration without running it: migrate diff --from-empty --to-schema-datamodel ... --script > migrations/0_init/migration.sql, then migrate deploy, then migrate resolve --applied 0_init. Without it the first deploy fails on duplicate objects.
Question
State the expand-contract pattern in order.
Click to reveal answer
Answer
Expand: add the new column (nullable/defaulted) and ship dual-write code. Migrate: backfill in batches, verify counts. Switch reads to the new column. Contract: drop the old column in a later release once no live code names it. Each release stays compatible with the previous one.
Question
A migration died halfway - what is the recovery sequence?
Click to reveal answer
Answer
migrate status shows the failed entry (P3009), fix the SQL, record the decision with `migrate resolve --rolled-back <name>`, then `migrate deploy` re-applies it. Never hand-edit _prisma_migrations, and never run migrate reset outside development.
Question
How do you roll back a bad migration in Prisma?
Click to reveal answer
Answer
You do not - migrate is forward-only. Revert the code (seconds, if the schema stayed compatible with both versions), write a new migration to undo the DDL if needed, and restore rows from point-in-time backup if data was destroyed. The rollback unit is the deploy, not the migration.
Revision Notes
Key Takeaways
- 1. Migration files are the version history that makes dev, staging, and production converge on the same shape
- 2. migrate deploy belongs in CI with a single writer, never in every container entrypoint, and never paired with a reset
- 3. The migration must tolerate the code that is currently running, because deploys are not atomic
- 4. Renames and drops ship as expand then contract across releases; a single-release rename 500s every old instance with P2022
- 5. Prisma migrations are forward-only: rollback means reverting the deploy, writing a new migration, or restoring from backup
Interview Tips
- • "How do schema changes reach production?" - committed migration files, prisma migrate deploy in CI before the app deploy, single writer, status check in the pipeline
- • "Walk me through renaming a column safely." - expand (add + dual write), backfill in batches, switch reads, contract (drop) next release; quote P2022 as the failure you are avoiding
- • "A migration failed in production - now what?" - P3009/migrate status to locate it, fix the SQL, migrate resolve --rolled-back, redeploy; if data was lost, point-in-time restore - and say you never reset production
- • "What is your rollback strategy for DDL?" - there is no automatic down migration; keep the schema compatible with both versions so code rollback is seconds, schedule destructive steps after old code is gone
Cheat Sheet
Applying Schema Migrations
| Command | Writes files | Applies | Resets | Environment |
|---|---|---|---|---|
migrate dev |
yes | yes | on drift | local / preview |
migrate deploy |
no | pending only | never | CI / production |
db push |
no | direct DDL | no | prototypes only |
- run: npx prisma migrate deploy # single writer, before traffic
env: { DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }} }
- run: ./deploy.sh
# baseline an existing database
npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql
npx prisma migrate deploy
npx prisma migrate resolve --applied 0_init
# after a failed migration
npx prisma migrate resolve --rolled-back 20250304101500_add_order_items
npx prisma migrate deploy && npx prisma migrate status
Expand-contract: add column -> dual write -> batched backfill -> switch reads -> drop old column in a later release.
Guardrails: every migration must be backward compatible with the code currently live; P3001 destructive change, P3009 failed migration; CREATE INDEX CONCURRENTLY cannot run inside a Prisma migration transaction.