The Prisma migration safety checklist — 12 checks + 6 outage stories
I’ve been on the wrong end of enough 2am Slack messages to know the pattern. "Hey, the login endpoint is throwing 500s in prod." "The seed script can’t find the users.email column." "Staging is fine, prod is broken, please look." Every time — every time — it traces back to a migration that worked locally, passed CI, and blew up the moment it hit a real database with real concurrent writes and a real up-to-down version gap.
This post is the twelve-check Prisma migration safety list I wish every migration author kept pinned to their monitor. Each check comes with a short story from an actual outage I or a colleague has shipped. Some names are changed. All of the patterns are real.
At the end I’ll describe the Claude-powered pre-commit hook we’re building that runs these checks automatically on every migration file you stage — but first, the list.
The 12 checks
Every destructive change has a written-out rollback.
A destructive change is any DROP, any RENAME, any ALTER that changes a column type, any NOT NULL that wasn’t NOT NULL before. Prisma’s default migration engine does not generate a down-migration. You have to write it. If you can’t describe how to reverse the change in SQL before you commit, you don’t have a migration — you have a landmine.
users.email_address to users.email in one migration. Canary was fine. Prod was fine. The next deploy of the application — three weeks later — rolled back the app image to one that still expected users.email_address, and the user table was now unreadable to the old code. Fifteen-minute partial outage. Rollback would have been trivial if it existed.Column renames are done in two migrations, never one.
A single-migration column rename is almost always wrong in any environment where the application runs across multiple servers or has rolling deploys. The correct pattern: (1) add the new column, backfill it, and have the app write to both; (2) cut the app over to read from the new column; (3) drop the old column. Three deploys. Prisma’s rename shortcut collapses this into one migration — it works only if your app is entirely offline during the migration.
@@map + @map annotation is a frequent false sense of security here. It changes the logical-to-physical mapping but does not generate the three-step expand-and-contract sequence. A dev assumed it did. 14 minutes of 500s on the /billing endpoint.Adding a NOT NULL column has a default value or a backfill.
Adding NOT NULL without a default fails on any table with existing rows. Adding it with a default that doesn’t fit your data fails silently — you get every existing row stamped with the default, and the app now treats those as real values. Three paths are correct: add with a default, add nullable then backfill then alter to NOT NULL, or add nullable and leave it that way if null is acceptable. Pick one explicitly.
users.plan added as NOT NULL DEFAULT 'free'. Every grandfathered paid user got silently downgraded to free. Nobody noticed for 6 hours because the billing job ran at midnight. Refund queue for 3 days.Foreign keys reference tables that exist at migration time.
Prisma will let you reference a model that doesn’t have its migration applied yet in some configurations. The migration engine doesn’t always order migrations by foreign-key dependency when you merge branches. If you’re stacking migrations from multiple feature branches, check that the referenced table’s migration sorts before the referencing table’s migration lexicographically by timestamp.
Organization. Team B added Project with a FK to Organization. Timestamps were within 3 seconds of each other and the sort order put Project before Organization on deploy. 20 minutes of ERROR 1452 in prod during the blue-green window.Unique indexes on existing tables don’t conflict with current data.
Adding a @unique on a column where duplicates already exist fails at migration time. Fine locally with an empty dev database. Catastrophic in prod. Before you add a unique constraint, run a SELECT column, COUNT(*) FROM table GROUP BY column HAVING COUNT(*) > 1 against a recent prod snapshot.
users.phone_number. Worked on the dev seed. Failed at migration in prod because 400 users had a legacy shared "+1-555-HELLO" placeholder. Migration blocked. Deploy blocked. Hotfix at midnight.Indexes created on large tables use CREATE INDEX CONCURRENTLY.
Prisma does not, by default, generate CREATE INDEX CONCURRENTLY for PostgreSQL. A regular CREATE INDEX holds an ACCESS EXCLUSIVE lock on the table for the duration. On a 50M-row table that’s a minute of full lock — meaning every read and write hangs for that minute. Your app will not be dead, but it will look like it is.
events(tenant_id, created_at) on a 120M-row table took 4 minutes. The four-minute lock queued enough writes that the connection pool saturated and started timing out other queries. 11-minute partial outage for a 4-minute index creation.The seed script still works after this migration.
Prisma seeds are separate from migrations but coupled to the schema. If you drop a column, rename a model, or change a required field, the seed script may still reference the old shape. This only bites you when a new developer clones the repo and runs prisma migrate reset — often weeks after the migration landed.
users.role in favor of a separate UserRole model. Seed script still did prisma.user.create({ data: { ..., role: 'admin' }}). Two weeks later a contractor joined, ran reset, got a cryptic Prisma error, filed a ticket, lost half a day.Every model’s migration file has a matching TypeScript regeneration.
Merging a migration without running prisma generate pushes a schema change to your co-workers’ branches without the matching type definitions. Their TypeScript compiles fine (because they have the old types); your PR compiles fine; the merged code is broken in a way that slips through both CIs. Every migration PR should include the regenerated node_modules/.prisma/client content check or an explicit step in CI.
Post.slug field was renamed to Post.urlSlug. The migration branch generated fresh types. Three other in-flight PRs merged with stale types. CI green on all four. Production build red.Non-null field additions have a narrow time window or a backfill strategy.
On large tables, even adding a nullable column can be slow under heavy write load because PostgreSQL rewrites the table. If the column is NOT NULL, you’re also rewriting every row to set the default. Prefer short pre-announced maintenance windows for these; avoid them during peak traffic.
orders.stripe_customer_id added as NOT NULL DEFAULT '' at 2pm on a Tuesday. Took 90 seconds on the 85M-row table. That’s 90 seconds of elevated p99 on every checkout. Support load spiked.Migration names describe intent, not just action.
20260401_add_field.sql tells you nothing. 20260401_add_stripe_customer_id_to_orders.sql tells the next human (or LLM) exactly what to audit. Good migration names are a security control — they surface dangerous changes to reviewers before they have to read SQL.
20260401_update_users.sql because the PR description looked fine. Didn’t read the SQL. Migration dropped users.api_key column. API keys revoked across 3,000 tenants. 9-hour recovery.You’ve run the migration against a recent prod snapshot locally.
Dev databases are tiny, clean, and schema-current. Prod databases are none of those. Keep a weekly-refreshed anonymized snapshot of prod available to every engineer. Run your migration against it before merging. This catches ~40% of the issues on this list automatically.
Your deployment pipeline halts between prisma migrate deploy and app deploy.
Applying the migration and deploying the new app image should be two separate pipeline steps with a manual (or automated) gate in between. When something goes wrong, that gate is the difference between "rolled back in 30 seconds" and "schema in state A while app expects state B." Zero-downtime deploys require this gate to be short; observable deploys require it to exist at all.
How the checklist ages
About twice a year I revise this list. Patterns #3 and #4 are ten years old — they’re not going anywhere. Patterns #6 and #9 are postgres-specific and would be different for MySQL or SQLite. Patterns #8 and #12 are Prisma-era — they didn’t exist on the same list five years ago, and they’ll probably evolve as the migration tooling does.
The point of the list isn’t that it’s exhaustive. The point is that the 12 things on it are the 12 things you can’t afford to forget, and all 12 of them are detectable from the migration file plus a bit of surrounding context (the seed script, the model definition, the recent schema history). Which means they’re all automatable.
The automation layer
Here’s the structural problem with checklists: they require a human to remember to use them, at the exact moment they’re most tired, on the thing that feels most routine. Friday afternoon. Last commit before vacation. 11pm push after a long day.
The right place to run this list is not on a PR review 4 hours later. It’s at git commit — before the migration has even left your laptop. That’s what we’re building with Septim Guard.
Septim Guard: the pre-commit hook version of this list
Detects any file in your migrations directory (Prisma, Drizzle, TypeORM, Sequelize, Rails, Django, Alembic, Flyway, Liquibase, plus a custom path option). Sends the diff + the surrounding schema context to Claude with a checklist-shaped prompt. Returns a structured verdict in <6 seconds. High-severity findings block the commit. Soft findings warn but pass. Hard override with --no-verify.
Launch list is $29 founding rate for the first 50 seats. Shipping June 2026. Pay $0 now. Reserve your seat →
Until Guard ships: three things you can do this week
Even without a pre-commit hook, you can close ~70% of the gap tonight:
1. Pin this checklist in your repo README
Drop the 12-check list (or a link to this page) in /docs/MIGRATION_CHECKLIST.md and reference it from your PR template under a "Migration safety" checkbox. Make the author tick each box before the PR is reviewable. Psychological friction beats zero friction.
2. Add a simple prod-snapshot script to your Makefile
make db-snapshot-test: \
pg_dump --no-owner --no-privileges prod | \
psql local-snapshot-db && \
prisma migrate dev --preview-feature
Whatever your version of this is — the idea is to make running your migration against a prod-shaped schema one command, not 15 minutes of VPN.
3. Require two approvers on any migration PR
The single cheapest governance control that actually works. Two humans who have to read the SQL before it merges. Most teams don’t do this because it feels bureaucratic. Most teams also have the outage stories above.
Closing
Migrations fail in the same patterns every time. That’s good news: it means the patterns are reviewable by any reviewer who knows the list — human, checklist, or LLM. The twelve above are the ones that matter. Pin the list. Run the snapshot. Require two approvers. And when Guard ships, run the checklist automatically on every commit, so you don’t have to remember.
Already using Tether?
Septim Tether ($19) runs Claude on your general pre-commit diff. Septim Guard ($29 founding) specializes in migration files with 11 framework presets and schema-aware checks. Tether owners upgrade to Guard for $15 when it launches.
Further reading
- How to set up Claude Code PR review in 2026 (3 options, real tradeoffs) — PR-review pattern survey, covers the CI layer this post doesn’t.
- The Tokenocalypse: why your Claude subagents burned $47K — same hook pattern, applied to cost instead of migrations.
- Septim Guard — the product page with the reservation form.
- Septim Tether — the $19 general-purpose pre-commit hook that Guard is a sibling of.