Prisma migrations are how you evolve your database schema over time without losing data or breaking your production app. Every time you change a model in schema.prisma, Prisma generates a SQL migration file that describes exactly what needs to change in the database. You review it, commit it, and apply it.
That process sounds simple. And for the happy path, it is. But in a production SaaS app, the details matter — which command you run when, how you handle deployments, when to write migrations by hand, and what to do when something goes wrong.
This post covers Prisma migrations from first principles through the patterns that matter most in a real app, using Plainform's PostgreSQL setup as a concrete example.
What Prisma Migrations Are
Prisma uses a declarative schema. You define what your database should look like in schema.prisma, and Prisma figures out what SQL needs to run to get there.
A migration is just the diff between your current schema and the new one, expressed as a SQL file. Prisma generates and stores these files in prisma/migrations/, with each migration in its own folder named with a timestamp and a label:
prisma/migrations/
├── 20250811234121_init/
│ └── migration.sql
├── 20260410180816_add_icon_field_to_events/
│ └── migration.sql
└── migration_lock.tomlWhat each file does
The timestamp ensures migrations run in order. The label is for humans — it describes what the migration does. The migration_lock.toml file records which database provider you are using, so Prisma can warn you if the provider changes unexpectedly.
Each migration.sql file is plain SQL. Prisma generates it, but you can read and edit it. You are not locked into opaque binary files — you can see exactly what will run against your database before it runs.
The Development Workflow
In development, you use prisma migrate dev. This command does three things:
- Detects the difference between your
schema.prismaand the current database state - Generates a new SQL migration file for that diff
- Applies the migration to your development database immediately
npx prisma migrate dev --name add_user_preferencesThe --name flag is optional but highly recommended. It becomes part of the folder name, making your migration history readable at a glance. Without it, Prisma prompts you for a name or leaves the folder unnamed.
What the generated SQL looks like
After running this command, a new folder appears in prisma/migrations/ with a SQL file inside. That file is now part of your source code — commit it alongside the schema change that triggered it.
Here is what a real migration from Plainform looks like. When an icon field was added to the Event model, Prisma generated this:
-- AlterTable
ALTER TABLE "public"."Event" ADD COLUMN "icon" TEXT;
-- CreateIndex
CREATE INDEX "Event_type_idx" ON "public"."Event"("type");Two operations: add the column, create an index. Exactly what the schema change asked for. Nothing more.
And the initial migration that created the Event table from scratch:
-- CreateTable
CREATE TABLE "public"."Event" (
"id" TEXT NOT NULL,
"type" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Event_pkey" PRIMARY KEY ("id")
);Plain SQL. If you need to review what a migration does before applying it, you just open the file.
The Production Workflow
In production, you never run prisma migrate dev. That command is designed for development — it can reset your database if it detects drift, which is useful locally and catastrophic in production.
Instead, you use prisma migrate deploy:
npx prisma migrate deployThis command applies any pending migrations that have not yet been applied to the database, in order. It does not generate new migrations, does not prompt for input, and does not reset anything. It is safe to run in a deployment pipeline.
The full deployment sequence
The typical flow when deploying a schema change:
- Developer changes
schema.prismalocally - Runs
npx prisma migrate dev --name describe_the_changeto generate the migration - Commits both
schema.prismaand the new migration file - Deployment pipeline runs
npx prisma migrate deployagainst the production database - Migration is applied, app starts with the updated schema
Generating the Prisma client
In Plainform, the postinstall script runs prisma generate to regenerate the Prisma Client after dependencies are installed:
{
"scripts": {
"postinstall": "prisma generate"
}
}prisma generate and prisma migrate deploy are separate concerns. Generating the client updates the TypeScript types in your code. Deploying migrations updates the actual database. You need both when deploying a schema change.
The Schema File
Everything starts with schema.prisma. Here is Plainform's full schema as it stands today:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
model Event {
id String @id @default(cuid())
type String
slug String
text String
icon String?
timestamp BigInt
@@index([type])
}
model Comment {
id Int @id @default(autoincrement())
page String @default("default") @db.VarChar(256)
thread Int? @map("threadId")
author String @db.VarChar(256)
content Json @db.Json
timestamp DateTime @default(now()) @db.Timestamp()
rates Rate[]
@@index([page])
}
model Rate {
userId String @db.VarChar(256)
commentId Int
like Boolean
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
@@id([userId, commentId])
@@index([commentId])
}The directUrl field
The directUrl field on the datasource is used alongside url. In connection-pooled environments like Supabase or Neon, url points to the pooler and directUrl points to the raw database connection. Prisma migrations require a direct connection — they cannot run through a connection pooler. The directUrl is how you satisfy that requirement without changing your main connection string.
Indexes in the schema
The @@index directives on frequently queried fields (type on Event, page on Comment, commentId on Rate) show up as CREATE INDEX statements in the generated migrations. Defining indexes in the schema keeps them version-controlled alongside the rest of your database definition.
Adding a Column Safely
One of the most common schema changes is adding a new column to an existing table. The safest way to do this in production is to make the column optional (? in Prisma syntax, NULL in SQL) first.
Say you want to add a metadata field to the Event model:
model Event {
id String @id @default(cuid())
type String
slug String
text String
icon String?
metadata Json? // new field
timestamp BigInt
@@index([type])
}Run prisma migrate dev --name add_metadata_to_events. Prisma generates:
-- AlterTable
ALTER TABLE "public"."Event" ADD COLUMN "metadata" JSONB;Because the column is nullable, existing rows are unaffected. The column is added with NULL for all existing records. No backfill needed.
Adding a non-nullable column
If you need the column to be non-nullable long-term, the safe sequence is:
- Add it as nullable and deploy
- Backfill the column with a default value for existing rows
- Add a
@default(...)or make it required once all rows have a value, then deploy again
Trying to add a non-nullable column without a default to a table with existing rows will fail. Prisma will warn you and often refuse to generate the migration without your explicit acknowledgment.
Renaming a Column
Renaming a column is where Prisma migrations require more care. If you rename slug to path in the schema, Prisma sees it as "drop slug, add path" — not as a rename. The generated migration will drop the old column and create a new empty one, losing all existing data.
Option 1: Use the @map attribute
This tells Prisma the TypeScript field is named path but the underlying database column is still slug. No migration is generated — no change happens in the database:
model Event {
path String @map("slug")
}Option 2: Write the migration manually
Generate an empty migration with --create-only, then write the SQL yourself:
npx prisma migrate dev --name rename_slug_to_path --create-onlyThis creates the migration file without applying it. Open the file and write the rename:
ALTER TABLE "public"."Event" RENAME COLUMN "slug" TO "path";Then apply it with npx prisma migrate dev. Prisma will run the SQL you wrote rather than the generated version.
The --create-only flag is useful whenever the generated SQL is not quite right — renames, complex index changes, or data migrations that need custom logic.
Handling Drift
Schema drift happens when your database state does not match what Prisma's migration history expects. This can happen if someone runs raw SQL against the database, if a migration was applied manually, or if the database was restored from a backup to a different point.
Checking migration status
You can check for drift with:
npx prisma migrate statusThis compares the migration history in the _prisma_migrations table (which Prisma manages in your database) against the files in prisma/migrations/. It tells you which migrations have been applied, which are pending, and whether the database has drifted from the expected state.
Resolving drift in development vs. production
In development, if drift is detected and you want to reset to a clean state:
npx prisma migrate resetThis drops the database, recreates it, and applies all migrations from scratch. Only run this in development — it is destructive by design.
In production, you investigate the drift and resolve it carefully. Sometimes that means writing a manual migration to bring the database back in line. Sometimes it means using prisma migrate resolve to mark a migration as applied or rolled back without running the SQL.
Seeding the Database
Once you have your schema in place, you often need seed data — for development, for tests, or for a fresh deployment. Prisma supports a seed script that runs after prisma migrate reset or on demand.
Writing a seed script
// prisma/seed.ts
import { prisma } from '@/lib/prisma/prisma';
async function main() {
await prisma.event.createMany({
data: [
{
type: 'page_view',
slug: '/blog',
text: 'Blog page viewed',
timestamp: BigInt(Date.now()),
},
{
type: 'signup',
slug: '/sign-up',
text: 'New user signed up',
timestamp: BigInt(Date.now()),
},
],
});
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());Register the script in package.json:
{
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}Then run it with npx prisma db seed, or it runs automatically after prisma migrate reset. Seed scripts are especially useful for populating lookup data, test accounts, or default configuration that every environment needs.
Prisma Migrate vs. db push
There is another Prisma command that often causes confusion: prisma db push. It pushes your schema directly to the database without creating migration files.
This sounds convenient. And for quick prototyping or spinning up a throwaway test database, it is. But it is not suitable for production or for any database you care about.
prisma db push does not create a migration history. There is no record of what changed or when. If two developers use it independently on the same project, the changes cannot be reconciled cleanly. And you cannot reproduce the database state on a fresh machine.
Prisma migrations via migrate dev and migrate deploy are the right choice for anything beyond a throwaway prototype. They give you a version-controlled, reproducible audit trail of every schema change.
A Note on Connection Pooling
One detail that trips people up when deploying Prisma migrations to platforms like Supabase or Neon is connection pooling. These platforms route connections through a pooler by default, and Prisma migrations require a direct database connection.
The solution is directUrl in schema.prisma:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // pooled connection for runtime
directUrl = env("DIRECT_URL") // direct connection for migrations
}At runtime, your app uses the pooled DATABASE_URL to handle concurrent connections efficiently. During migrations, Prisma uses DIRECT_URL to connect directly without going through the pooler. Both environment variables point to the same database — they just use different connection strings.
If you see errors like "prepared statement does not exist" or migration failures in a pooled environment, this is almost always the fix.
Summary
Prisma migrations give you a version-controlled, reproducible way to evolve your database schema. The core workflow is:
- Use
prisma migrate devin development to generate and apply migrations - Commit migration files alongside schema changes
- Use
prisma migrate deployin production to apply pending migrations safely - Use
--create-onlywhen you need to write or edit migration SQL manually - Add
directUrlto your datasource when using a connection pooler - Avoid
db pushfor anything beyond throwaway prototyping
The migration history in prisma/migrations/ is part of your codebase. It tells the complete story of how your database got to its current state — and it is what lets you reproduce that state on any machine, in any environment, reliably.
