Skip to content

Running Prisma migrations on Vercel without breaking production

Put prisma generate and prisma migrate deploy in the Vercel build command, never db push or migrate dev, and design migrations to survive a rolling deploy.

Prisma5 min readships at docs/solutions/prisma/migrate-deploy-on-vercel.md

Tags: prisma · vercel · migrations · deployment · postgres

You deploy to Vercel and the site 500s with:

PrismaClientKnownRequestError:
The table `public.project` does not exist in the current database.

The table exists locally. The migration is committed. The build succeeded. Nothing applied it, because nothing was ever told to.

Vercel builds your code. It does not know your database exists.

Where migrations belong

Run them in the build command, before next build:

bun run db:generate && bun run db:migrate:deploy && bun run build

Set that in Vercel under Settings → Build & Development Settings → Build Command (override it), or in vercel.json:

{
  "buildCommand": "bun run db:generate && bun run db:migrate:deploy && bun run build"
}

Three commands, in this order, for three reasons:

db:generate (prisma generate) because the client is generated code that is not in git. Prisma 7 writes it to the folder the generator block names (in this repo src/generated/prisma, which .gitignore lists). This repo also runs generate from postinstall, which covers every clone and every cold CI job, but an install that is a no-op on a cache hit may not run lifecycle scripts at all. Then the build either cannot find the client or typechecks against one generated from an older schema, so the build command says it out loud.

db:migrate:deploy (prisma migrate deploy) because it is the only migration command that is safe to run unattended. It applies pending migrations in order and stops on the first failure. It never prompts, never resets, never generates a new migration.

build last so a failed migration fails the deploy before any new code goes live.

The commands that must never run here

  • prisma migrate dev compares the schema to a shadow database and can offer to reset. Unattended, against production, that is catastrophic. It is a development command, full stop.
  • prisma db push applies the schema with no migration history. It resolves differences by dropping columns and tables, silently. It is for a local scratch database while you are still shaping a model.
  • Manual psql DDL puts the database out of step with _prisma_migrations, and the next migrate deploy reports drift.

Migrations must not need DATABASE_URL to be the pooled one

migrate deploy connects with the URL in prisma.config.ts, never the one the app's driver adapter uses, so the two can differ:

// prisma.config.ts
export default defineConfig({
  schema: "prisma/schema.prisma",
  datasource: { url: process.env.DIRECT_URL }, // direct, unpooled
});

Prisma 7 loads no .env file by itself, and Vercel does not need one: it sets the variables in the build environment. On your laptop, prisma.config.ts has to load .env.local itself; this repo's does.

The direct string must exist in Vercel for every environment: Production, Preview and Development. The most common "it works in production but every preview deploy fails" cause is a direct URL that was only added to Production. A migration through a transaction pooler hangs on an advisory lock rather than failing cleanly, so the error you get is a timeout with no useful detail. (This repo's config falls back to rewriting the pooled Neon or Supabase host when the direct string is missing, which saves you on those two providers and nowhere else.)

Design migrations for a rolling deploy

Even with the ordering above, there is a window (usually seconds, sometimes longer) where the migration has applied and old instances are still serving traffic with the old code. Any migration that removes or renames something breaks those instances.

The rule is expand, then contract, across two deploys.

Renaming name to fullName:

Deploy 1: expand. Add fullName as nullable. Backfill it. Ship code that writes both columns and reads fullName ?? name.

ALTER TABLE "user" ADD COLUMN "full_name" TEXT;
UPDATE "user" SET "full_name" = "name" WHERE "full_name" IS NULL;

Deploy 2: contract. Once no running code reads name, drop it and add the constraint.

ALTER TABLE "user" ALTER COLUMN "full_name" SET NOT NULL;
ALTER TABLE "user" DROP COLUMN "name";

The same shape applies to adding a required column (add nullable → backfill → set not null), and to narrowing a type. Prisma writes single-step SQL by default because it cannot know your deploy strategy: split it yourself with:

bunx prisma migrate dev --create-only

which writes the migration file and lets you edit the SQL before it is applied.

Long migrations and the build timeout

migrate deploy runs inside the build, and builds have a time limit. A CREATE INDEX on a large table, or a backfill of millions of rows, will hit it, and a build killed mid-migration leaves a failed row in _prisma_migrations that blocks every later deploy.

For those, do the work out of band:

  • Build indexes with CREATE INDEX CONCURRENTLY in a manually-run migration (it cannot run inside a transaction, so it needs its own migration file and a deliberate execution), then mark it applied with bunx prisma migrate resolve --applied <name>.
  • Backfill in batches from a script you run yourself, not from a migration.

Recovering from a failed deploy migration

prisma migrate deploy stops at the first failure and records it. Every subsequent deploy then fails immediately with "migration failed to apply".

bunx prisma migrate status

Read what it says, fix the SQL, then tell Prisma what actually happened to the failed migration:

# the migration's changes are NOT in the database
bunx prisma migrate resolve --rolled-back 20260401120000_add_project

# you applied the changes by hand and they ARE in the database
bunx prisma migrate resolve --applied 20260401120000_add_project

Then redeploy. Never delete rows from _prisma_migrations to make the error go away: you are deleting the record of what your database contains.

Preview deployments

Preview branches sharing the production database is the default and it is a trap: a preview build applies migrations to production. Either point previews at a branch database (Neon and Supabase both create one per branch) or drop migrate deploy from preview builds and let production be the only thing that migrates.

The checklist

  • Build command is generate && migrate deploy && build.
  • DATABASE_URL (pooled) and the direct string (DIRECT_URL, or DATABASE_URL_UNPOOLED on Neon) set in all three Vercel environments.
  • prisma/migrations/** committed alongside the schema change.
  • Destructive changes split into expand and contract deploys.
  • bunx prisma migrate status clean after the deploy.