⚠ Every broken Prisma migration in production looks fine when you wrote it.
· guide ·

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

01 · CRITICAL

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.

Story: a team renamed 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.
02 · CRITICAL

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.

Story: the Prisma @@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.
03 · CRITICAL

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.

Story: 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.
04 · CRITICAL

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.

Story: two teams merged on Friday afternoon. Team A added 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.
05 · HIGH

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.

Story: added a unique constraint on 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.
06 · HIGH

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.

Story: a composite index on 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.
07 · HIGH

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.

Story: team dropped 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.
08 · HIGH

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.

Story: a 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.
09 · MEDIUM

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.

Story: 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.
10 · MEDIUM

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.

Story: PR reviewer approved 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.
11 · MEDIUM

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.

Story: the unique-constraint-on-phone outage in check #5 would have been caught in 30 seconds against a prod snapshot. The team had a snapshot, but using it required an AWS VPN login that took 15 minutes, so nobody bothered.
12 · LOW

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.

Story: unified CI step ran migration + deploy atomically. Migration succeeded; app image failed to pull from the registry (transient). App was down; schema was the new version; rolling back the app meant rolling back the schema. 6-minute outage for a 30-second fix.

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