Plainform

Mobile Sidebar

Mobile Sidebar

Prisma Migrations: A Practical Guide for SaaS Apps

1 Jun 2026
11 minute read
R
Ronald SolticzkiBackend Engineer
Prisma Migrations: A Practical Guide for SaaS Apps

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.toml

What 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:

  1. Detects the difference between your schema.prisma and the current database state
  2. Generates a new SQL migration file for that diff
  3. Applies the migration to your development database immediately
npx prisma migrate dev --name add_user_preferences

The --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 deploy

This 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:

  1. Developer changes schema.prisma locally
  2. Runs npx prisma migrate dev --name describe_the_change to generate the migration
  3. Commits both schema.prisma and the new migration file
  4. Deployment pipeline runs npx prisma migrate deploy against the production database
  5. 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:

  1. Add it as nullable and deploy
  2. Backfill the column with a default value for existing rows
  3. 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-only

This 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 status

This 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 reset

This 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 dev in development to generate and apply migrations
  • Commit migration files alongside schema changes
  • Use prisma migrate deploy in production to apply pending migrations safely
  • Use --create-only when you need to write or edit migration SQL manually
  • Add directUrl to your datasource when using a connection pooler
  • Avoid db push for 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.

Comments on this page

Leave comment

Stay up to date with our latest product updates. Unsubscribe anytime!

2026 © All rights reserved