migrate dev vs migrate deploy: The Most Important Distinction in Prisma
prisma migrate dev is for local development only — it creates new migration files and requires interactive confirmation; prisma migrate deploy applies pending migrations silently and is the only command safe for production. I deploy with postinstall generate and directUrl for DDL on the same 6-model multi-tenant app with 10 tools, 58,641 lines of TypeScript, and $20-60/month AI spend on Vercel production. Platform totals from production: 10 tools, 6 networks, $20-60/month AI spend, 58,641 lines, 4-month solo build.
See also: multi-tenant Prisma schema patterns and production deployment patterns on managed hosts.
I learned this the hard way on an affiliate marketing SaaS I built. The first Vercel deploy hung for 30 minutes with no error message. The build log showed prisma migrate dev waiting at: "✔ Are you sure you want to apply the above changes?" CI has no TTY. The command waits forever. The job times out. The deploy never completes.
migrate dev creates new migration files from schema changes, prompts for confirmation, and may recreate the database if a migration fails. Use it locally when you change schema.prisma and run npx prisma migrate dev --name describe_change.
migrate deploy reads the prisma/migrations/ folder, applies only migrations not yet recorded in the database, verifies SHA-256 checksums of existing files, and exits silently. No prompts. This is what production, staging, and CI use. If you edit an applied migration file, the stored checksum no longer matches — deploy fails with "Migration was modified after being applied." Create a new migration to fix mistakes; never rewrite history.
The simple rule: migrate dev for local. migrate deploy for everywhere else.
| Command | Creates migrations | Requires interaction | Safe in production | Use when |
|---|---|---|---|---|
| prisma migrate dev | ✅ Yes | ✅ Yes (interactive) | ❌ Never | Local development only |
| prisma migrate deploy | ❌ No (applies existing) | ❌ None | ✅ Always | CI/CD, Vercel, staging |
| prisma db push | ❌ No history | ❌ No | ❌ Never in prod | Prototyping only |
| prisma migrate reset | ❌ Destroys + recreates | ✅ Yes | ❌ NEVER | Local reset only |
| prisma migrate status | ❌ Read-only | ❌ No | ✅ Safe | Check migration status |
Using prisma migrate dev in your Vercel build command or GitHub Actions workflow will cause the deployment to hang indefinitely — the command waits for interactive input that CI/CD cannot provide. The job will timeout without error, the deploy will never complete, and you'll spend 30 minutes wondering what's wrong. Always use prisma migrate deploy in production.
Gotcha 2: Generating the Prisma Client on Vercel (The postinstall Script)
Vercel's build environment starts fresh with no generated Prisma client — add "postinstall": "prisma generate" to package.json so the client is generated automatically after npm install runs.
Why Vercel doesn't have the Prisma client
On your local machine, running prisma generate creates the client in node_modules/.prisma/client or a custom path like src/generated/prisma. That generated code persists between sessions. Vercel's build container starts from scratch on every deploy. It runs npm install, which populates node_modules from package.json — but Prisma's generate step is not part of npm install unless you explicitly trigger it.
Without generation, the build fails with: "Cannot find module '@prisma/client'" or "The @prisma/client did not initialize yet. Please run prisma generate." I've seen both on first deploys before adding the postinstall script. With Prisma 7 and a custom output path, the error may reference your custom import path instead — same root cause, same fix.
Two places to add generate (belt and suspenders)
{
"scripts": {
// Runs automatically after npm install — generates Prisma client
"postinstall": "prisma generate",
// Migrations first, then build — halts if migration fails
"build": "prisma migrate deploy && next build",
"dev": "next dev --turbopack",
"start": "next start",
"lint": "next lint"
}
}
postinstall: Vercel runs npm install before the build command. The postinstall hook fires automatically and runs prisma generate. This handles the custom output path if configured in schema.prisma — Prisma reads the schema automatically.
build: prisma migrate deploy runs first, then next build. The && operator means if migrate deploy returns a non-zero exit code, next build never runs. Belt-and-suspenders: postinstall handles generate; build handles migration. Even if one path is skipped, the other covers you.
The custom output path complication (Prisma 7)
On the platform I built, the Prisma client generates to a custom path — not the default node_modules location:
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma" // NOT the default node_modules location
}
postinstall: "prisma generate" still works — Prisma reads schema.prisma and writes to the configured output. But the import path changes. import { PrismaClient } from '@prisma/client' breaks. import { PrismaClient } from '@/generated/prisma' is correct.
After deploy, check Vercel function logs on the first request. If you see "Cannot find module" — the generate step didn't run or the import path is wrong.
Always verify after the first Vercel deploy that the Prisma client was generated. Go to Vercel → Deployment → Functions → check logs for any import errors on the first request. Better to catch it here than in production traffic.
Gotcha 3: The directUrl for Migrations Through Connection Poolers
If your PostgreSQL is behind a connection pooler like Neon's PgBouncer, migrations will fail without a directUrl — poolers don't support the DDL statements that migrations require.
What connection poolers break in Prisma Migrate
The platform uses Neon Serverless Postgres. Application queries go through Neon's connection pooler (PgBouncer) — serverless-safe, handles connection limits. Migrations require DDL: CREATE TABLE, ALTER TABLE, DROP TABLE. PgBouncer uses prepared statements mode, which does not support DDL in transaction mode.
The errors you get running prisma migrate deploy through the pooler URL:
- "cannot start a transaction in prepared statements mode"
- "prepared statement X already exists"
These look like database bugs. They are pooler limitations. The fix is a direct connection URL for migrations only.
The schema.prisma with directUrl
generator client {
provider = "prisma-client-js"
output = "../src/generated/prisma" // custom output — adjust if using default
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // pooler URL — for app queries
directUrl = env("DIRECT_URL") // direct URL — for migrations only
}
// DATABASE_URL (pooler — used by running application):
// postgresql://user:pwd@ep-xxx-pooler.region.neon.tech/db?pgbouncer=true
// DIRECT_URL (direct — used by prisma migrate deploy only):
// postgresql://user:pwd@ep-xxx.region.neon.tech/db?sslmode=require
DATABASE_URL: Neon's pooler connection. Used by the running Next.js app for all queries — Server Actions, API routes, background jobs. Serverless functions open short-lived connections; the pooler manages them efficiently.
DIRECT_URL: Neon's direct (non-pooler) connection. Used by prisma migrate deploy and prisma generate only. Bypasses PgBouncer so DDL statements execute normally.
Vercel environment variable setup for two URLs
Add both to Vercel → Settings → Environment Variables for Production (and Preview if you run migrations on preview deploys):
- DATABASE_URL: pooler URL with
?pgbouncer=true - DIRECT_URL: direct URL with
?sslmode=require
If DIRECT_URL is missing, Prisma falls back to DATABASE_URL for migrations. The pooler blocks DDL. Migration fails with confusing database-level errors that send you down the wrong debugging path for hours.
Both URLs must point to the same database — different connection endpoints, same data. Neon provides both URLs in the project dashboard. Copy the "Pooled connection" string for DATABASE_URL and the "Direct connection" string for DIRECT_URL.
Integrating Migrations Into the Vercel Build Pipeline
This Prisma migrations production Next.js Vercel deployment guide recommends adding prisma migrate deploy before next build in your build command — if migration fails, the build halts and the old version stays deployed, preventing new code from running against an outdated schema.
The build command approach (solo devs and small teams)
The build script I use in production:
"build": "prisma migrate deploy && next build"
The && operator is intentional. If prisma migrate deploy returns a non-zero exit code — schema error, connection failure, checksum mismatch — next build never runs. Vercel marks the deployment as failed. The previous production version stays live. This is correct behaviour. You do not want new application code deployed against an old database schema.
Vercel's pipeline runs: npm install (triggers postinstall → prisma generate) → build command (migrate deploy → next build) → deploy. For solo developers and small teams with fast DDL migrations, this is sufficient.
Trade-offs: if migration fails, build fails — no rollback needed because the old version never changed. Vercel build timeout may kill long-running data migrations. Use this when schema migrations are fast DDL only, not multi-step data transformations. For the platform's six models, every migration has been additive DDL completing in under a second — the build command approach has never caused a timeout.
The GitHub Actions approach (teams and complex migrations)
name: Deploy to Production
on:
push:
branches: [main]
jobs:
migrate-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Generate Prisma client
run: npx prisma generate
# Migration runs BEFORE Vercel deploy — schema updated first
- name: Run database migrations
run: npx prisma migrate deploy
env:
DIRECT_URL: ${{ secrets.DIRECT_URL }} # direct connection for DDL
DATABASE_URL: ${{ secrets.DATABASE_URL }} # required by Prisma client
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
When to use GitHub Actions vs build command
Build command: sufficient for schema-only migrations — adding tables, nullable columns, indexes. Sub-second DDL. What I use on the platform with six Prisma models and multiple migrations over the product lifetime.
GitHub Actions: needed when migrations involve data transformations, multiple steps, or when you need the migration to succeed before the deploy trigger fires. Migration failure in GitHub Actions does not fail the Vercel build — you get a clear error in the workflow log instead of a hung deploy.
Zero-Downtime Migrations: Additive vs Destructive Changes
Additive migrations (adding columns, tables, indexes) are safe to run anytime — destructive changes (removing or renaming columns) require a staged multi-deploy approach to avoid breaking running code.
The safe migration types (run anytime)
- Adding a new table — no existing code references it
- Adding a nullable column — existing rows get NULL, no constraint violation
- Adding an index — background, non-blocking operation
- Adding a new enum value — backward-compatible if old code ignores new values
On the platform I built, all migrations were additive. New columns were nullable. New tables did not affect existing queries. I never needed the staged approach — by design.
The risky migration types (need staging)
- Removing a column: old running Vercel serverless instances still SELECT it → "column does not exist"
- Renaming a column: old code uses old name → crash
- Making a nullable column required: existing NULL rows → constraint violation
- Changing a column type: may fail with existing data
The 3-deploy zero-downtime strategy
For risky changes, spread the work across three deploys:
Deploy 1: Add new_column alongside old_column (nullable, no required constraint). Migration: ALTER TABLE t ADD COLUMN new_col type; Both old and new code work — old code ignores new_col, new code writes to both.
Data migration: separate job backfills new_col from old_col for existing rows.
Deploy 2: Switch application to read/write new_col exclusively. Stop referencing old_col. Both columns still exist — safe rollback possible.
Deploy 3: Drop old_col. Migration: ALTER TABLE t DROP COLUMN old_col; Safe — nothing reads this column anymore.
Design advice: make new schema additions nullable wherever possible so they don't require staging. The platform added all new columns as nullable — zero instances of the 3-deploy strategy needed across its migration history.
Migration files are the source of truth for your database schema history. Once a migration is applied and committed, treat it as immutable — the same way you'd treat a published git commit. Editing an applied migration causes a SHA-256 checksum mismatch on the next deploy. When something is wrong: create a new migration, never edit the old one.
Prisma 7 and Custom Client Output: The Import Path Change
If you configure a custom output path for the Prisma client in Prisma 7, every import must use that custom path — and the postinstall script must match the schema.prisma output configuration.
Why custom output is increasingly common in Prisma 7
Prisma 7 changed the client generation mechanism. The generated client is explicitly a generated artifact — many projects put it in src/generated/ for clarity. Benefits: visible in your project structure, never confused with installed packages, avoids hot reload conflicts in development. The platform uses Prisma 7 with custom output at src/generated/prisma across six models that evolved through multiple migrations over the product lifetime.
The import path change
Default (no custom output):
import { PrismaClient } from '@prisma/client' // default location
With custom output at src/generated/prisma:
import { PrismaClient } from '@/generated/prisma' // custom path — required
Create a singleton to avoid multiple client instances in development hot reload:
// src/lib/db.ts
import { PrismaClient } from '@/generated/prisma'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const db =
globalForPrisma.prisma ??
new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query'] : [] })
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
The gitignore decision
Option A — gitignore the generated directory (recommended):
# .gitignore
src/generated/
Pro: generated code not in git history. Con: must ensure postinstall always runs on Vercel. This is what I use — the postinstall script handles generation reliably.
Option B — commit the generated directory: faster Vercel builds (no generate step needed), but massive diffs every Prisma upgrade. Not worth it for most teams.
Hassan Raza documents production Next.js patterns — Prisma migrations, multi-tenant schema design, and Vercel deployment pipelines — across posts on hassanr.com. The postinstall + directUrl + migrate deploy combination in this guide is the same foundation behind every database-backed SaaS I ship.
Frequently Asked Questions
Add three things to your Vercel setup: a postinstall script, a build command, and two database URLs. First, add "postinstall": "prisma generate" to package.json — this runs after npm install and generates the Prisma client in Vercel's fresh build environment. Second, set your build command to "prisma migrate deploy && next build" so migrations run before the app compiles; if migration fails, the deploy halts. Third, add both DATABASE_URL (Neon pooler connection for app queries) and DIRECT_URL (direct connection for DDL) to Vercel environment variables. Never use prisma migrate dev in the build command — it requires interactive input and hangs indefinitely in CI.
Use additive migrations wherever possible — add new nullable columns instead of modifying existing ones, and add new tables instead of dropping or renaming. For risky changes like removing or renaming columns, use the 3-deploy strategy: Deploy 1 adds the new column alongside the old, a data migration backfills values, Deploy 2 switches code to the new column, and Deploy 3 drops the old column. On Vercel with Prisma, the build command approach (prisma migrate deploy && next build) keeps the old version deployed if migration fails — preventing new code from running against an outdated schema.
Always use prisma migrate deploy in production. prisma migrate dev is designed for local development — it creates new migration files and requires interactive confirmation. In CI/CD there is no TTY for input, so the command hangs indefinitely. prisma migrate deploy reads existing migration files and applies only pending ones with no interaction required. Never edit a migration file after it has been applied — Prisma stores SHA-256 checksums and fails on mismatch with "Migration was modified after being applied."