Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Database Migrations: EF Core vs Prisma and Drizzle

For .NET engineers who know: Add-Migration, Update-Database, migration bundles, IMigrationsSqlGenerator, and the discipline of never editing applied migrations You’ll learn: How Prisma and Drizzle handle the migration lifecycle — what they automate, what they leave to you, and the specific production deployment patterns that replace your EF Core workflows Time: 15-20 min read


The .NET Way (What You Already Know)

EF Core migrations are code. When you run Add-Migration, EF Core compares the current model snapshot against your entity classes and generates a C# migration class with Up() and Down() methods. Both directions are SQL executed through IDbMigrator. You can customize Up() and Down() with raw SQL, call stored procedures, or add seed data directly in the migration.

// EF Core migration — a class with explicit Up() and Down()
public partial class AddOrderStatusColumn : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>(
            name: "status",
            table: "orders",
            type: "nvarchar(50)",
            nullable: false,
            defaultValue: "pending");

        // Custom SQL in migrations is first-class
        migrationBuilder.Sql(@"
            UPDATE orders
            SET status = CASE
                WHEN shipped_at IS NOT NULL THEN 'shipped'
                ELSE 'pending'
            END");
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(name: "status", table: "orders");
    }
}

The workflow is:

  1. dotnet ef migrations add <Name> — generates the C# migration
  2. Review and optionally edit the migration file
  3. dotnet ef database update — applies to the target database
  4. In production CI/CD: dotnet ef database update or a migration bundle

EF Core tracks applied migrations in __EFMigrationsHistory. It will never re-apply a migration that is already recorded there.


Prisma Migrations

How Prisma Differs: Declarative, SQL-Only

Prisma’s migration approach is declarative. You describe what the schema should look like in schema.prisma, and Prisma figures out what SQL is needed to get there. The generated migration files are plain SQL — not C#, not JavaScript. There are no Up() and Down() methods.

The key difference: Prisma does not generate rollback SQL.

prisma/
  migrations/
    20260101000000_init/
      migration.sql          ← forward SQL only
    20260115000000_add_orders/
      migration.sql
  schema.prisma

Each migration directory contains one file: the SQL to apply. That is it. If you need to roll back, you write the rollback SQL yourself (or restore a database backup, which is the more common production strategy).

Development Workflow

# Step 1: Edit schema.prisma
# Step 2: Create migration and apply to dev DB
npx prisma migrate dev --name add_status_to_orders

# Step 3: Prisma generates and immediately applies the migration
# It also re-runs prisma generate to update the TypeScript client

prisma migrate dev does three things in order:

  1. Computes the diff between your current schema.prisma and the last migration
  2. Writes a new migration.sql file under prisma/migrations/<timestamp>_<name>/
  3. Applies the migration to your local development database
  4. Runs prisma generate to regenerate the TypeScript client

This is convenient but means you cannot preview the SQL before it runs in development. Use prisma migrate dev --create-only if you want to generate the file without applying it:

# Generate the SQL file but do not apply it yet
npx prisma migrate dev --name add_status_to_orders --create-only

# Review the file at prisma/migrations/20260115000000_add_status_to_orders/migration.sql
# Edit it if needed, then apply
npx prisma migrate dev

The Generated SQL

-- prisma/migrations/20260115000000_add_status_to_orders/migration.sql
-- Prisma generates clean, standard SQL

ALTER TABLE "orders" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'pending';

UPDATE "orders"
SET "status" = CASE
    WHEN "shipped_at" IS NOT NULL THEN 'shipped'
    ELSE 'pending'
END;

Wait — that UPDATE statement is not generated automatically by Prisma. Prisma generates only the DDL (the ALTER TABLE). If you need data migration logic (backfilling data based on existing columns), you must add the SQL manually to the generated migration file before applying it.

This is the fundamental limitation: Prisma migrations are SQL-only, and only DDL is auto-generated. Data migrations and complex transformations require manual SQL additions.

-- What Prisma generates automatically:
ALTER TABLE "orders" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'pending';

-- What you add manually for the data migration:
UPDATE "orders"
SET "status" = CASE
    WHEN "shipped_at" IS NOT NULL THEN 'shipped'
    ELSE 'pending'
END;

Prisma tracks applied migrations in _prisma_migrations (equivalent to __EFMigrationsHistory). It will not re-apply already-applied migrations.

Production Deployment

Never run prisma migrate dev in production. It is designed for development: it can reset the database and does interactive prompting. For production (and CI/CD):

# Apply pending migrations without interactive prompts
npx prisma migrate deploy

migrate deploy only applies pending migrations. It never generates new ones, never resets the database, never prompts. It is the command for your deployment pipeline.

# GitHub Actions — production deployment step
- name: Apply database migrations
  run: npx prisma migrate deploy
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

- name: Deploy application
  run: npm run start

The sequence matters: run migrate deploy before starting the new application version. Doing it in the other order means your new code may run against the old schema for a window of time.

Seeding

Prisma’s seed script is separate from migrations. It is a TypeScript file you write and register in package.json:

// prisma/seed.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // Use upsert — makes the seed idempotent (safe to run multiple times)
  const roles = await Promise.all([
    prisma.role.upsert({
      where: { name: 'admin' },
      update: {},
      create: { name: 'admin', permissions: ['read', 'write', 'delete'] },
    }),
    prisma.role.upsert({
      where: { name: 'viewer' },
      update: {},
      create: { name: 'viewer', permissions: ['read'] },
    }),
  ]);

  console.log(`Seeded ${roles.length} roles`);
}

main()
  .catch((e) => { console.error(e); process.exit(1); })
  .finally(() => prisma.$disconnect());
// package.json
{
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
  }
}
# Run seed manually
npx prisma db seed

# Seed also runs automatically after:
npx prisma migrate reset   # drops DB, re-applies all migrations, seeds

Seed data should be idempotent (using upsert, createMany with skipDuplicates, or connectOrCreate). This lets you run the seed safely in multiple environments without errors.

Squashing Migrations

After many iterations, you may have dozens of small migrations. You can squash them into one:

# Mark all migrations as applied without running them (e.g., for a fresh baseline)
npx prisma migrate resolve --applied 20260101000000_init

The proper squash workflow:

  1. Create a new migration with the desired combined SQL (by hand or by running db push on a fresh DB then diffing)
  2. Delete the old migration directories
  3. Mark the new baseline migration as applied on all existing databases with migrate resolve --applied
  4. New databases created from scratch will apply the squashed migration

This is more manual than EF Core’s squash approach, but the result is the same: fewer migration files to apply for a fresh database.

Handling Merge Conflicts in Prisma Migrations

When two branches both add migrations, you will have a conflict in the prisma/migrations directory. Prisma uses migration_lock.toml as a conflict detector:

# prisma/migrations/migration_lock.toml — auto-generated, check into git
# This file is used by Prisma to detect merge conflicts
provider = "postgresql"

When you merge branches with conflicting migrations, migration_lock.toml may conflict. After resolving the merge:

# Check current state
npx prisma migrate status

# If migrations are out of order or Prisma is confused, reset dev DB
npx prisma migrate reset

# Re-apply all migrations from scratch (dev only — destroys data)
# Prisma will detect the correct order from directory timestamps

For production, never reset. Order matters: both migrations apply in timestamp order. If branch A adds a users table and branch B adds a posts table with a users FK, and B’s timestamp is earlier, deploying in order will fail. Structure your team’s migration workflow to avoid this:

  • Long-lived feature branches should rebase before merging to pull in any new migrations
  • Use a migration gating step in CI that verifies prisma migrate status is clean before merging

Drizzle Migrations

How Drizzle Differs: Explicit SQL Files

Drizzle takes a different philosophy. drizzle-kit generate inspects your TypeScript schema definitions, diffs them against a snapshot of the previous state (stored in a meta/ directory alongside your migrations), and generates a SQL file. It does not apply the migration. Application is a separate explicit step.

drizzle/
  0000_initial.sql
  0001_add_orders.sql
  0002_add_status_column.sql
  meta/
    _journal.json        ← tracks which migrations exist and their order
    0000_snapshot.json   ← schema state after each migration
    0001_snapshot.json
    0002_snapshot.json

Development Workflow

# Step 1: Edit src/db/schema.ts

# Step 2: Generate migration SQL (does NOT apply it)
npx drizzle-kit generate

# Generated: drizzle/0003_add_status_column.sql
# Review the file — edit if needed

# Step 3: Apply the migration
npx drizzle-kit migrate

The generated SQL:

-- drizzle/0003_add_status_column.sql
ALTER TABLE "orders" ADD COLUMN "status" text DEFAULT 'pending' NOT NULL;

As with Prisma, data migration SQL must be added manually. Drizzle generates DDL only.

You can also apply migrations programmatically from application code — useful for integration tests or docker-compose startup:

// src/db/migrate.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';

async function runMigrations() {
  const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
  const db = drizzle(migrationClient);

  await migrate(db, { migrationsFolder: './drizzle' });
  await migrationClient.end();
}

runMigrations().catch(console.error);

This is a common pattern for serverless deployments or Docker-based test environments where you want migrations to run automatically on startup.

Production Deployment with Drizzle

# Option 1: CLI
npx drizzle-kit migrate

# Option 2: Programmatic (run as a startup script)
node dist/db/migrate.js
# GitHub Actions
- name: Run database migrations
  run: npx drizzle-kit migrate
  env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}

Or, more commonly with Drizzle, run migrations as part of application startup using the programmatic API — the application migrates itself on boot, then starts serving requests. This is the Kubernetes-friendly approach.

Drizzle Seeding

Drizzle has no built-in seed command. You write a seed script and run it however you like:

// src/db/seed.ts
import { db } from './index';
import { roles, users } from './schema';

async function seed() {
  await db
    .insert(roles)
    .values([
      { name: 'admin' },
      { name: 'viewer' },
    ])
    .onConflictDoNothing(); // idempotent

  console.log('Seeded roles');
}

seed()
  .catch(console.error)
  .finally(() => process.exit());
// package.json
{
  "scripts": {
    "db:seed": "ts-node src/db/seed.ts",
    "db:migrate": "drizzle-kit migrate",
    "db:generate": "drizzle-kit generate"
  }
}

Handling Merge Conflicts in Drizzle

Drizzle uses the meta/_journal.json file and numbered SQL files. If two branches both run drizzle-kit generate, you get a conflict in _journal.json and possibly conflicting migration numbers.

The correct resolution:

  1. Decide which migration should be applied first
  2. Renumber if needed (Drizzle uses the journal for order, not file names)
  3. Update _journal.json to reflect the correct order
  4. Verify by running drizzle-kit migrate --dry-run if supported, or inspect the journal

In practice, teams enforce “squash before merge” or “rebase before generating” to avoid this. One person generates migrations per feature branch; they do not generate independently on multiple branches touching the same tables.

Squashing Migrations in Drizzle

# Push the current schema state directly (no migration file, for dev baseline)
npx drizzle-kit push

# For squashing in production:
# 1. Combine the SQL from your old migration files into one file manually
# 2. Update meta/_journal.json to reference only the new file
# 3. Mark it as applied on existing databases

Drizzle does not have a built-in squash command. The approach is manual: consolidate the SQL files and update the journal. This is more work than Prisma’s migrate resolve, but gives you full control over the resulting SQL.


Side-by-Side Migration Workflow Comparison

StepEF CorePrismaDrizzle
Schema sourceC# entity classesschema.prismaTypeScript schema files
Create migrationdotnet ef migrations add <Name>prisma migrate dev --name <name>drizzle-kit generate
Apply to devdotnet ef database update(automatic with migrate dev)drizzle-kit migrate
Apply to productiondotnet ef database update or bundleprisma migrate deploydrizzle-kit migrate or programmatic
Preview SQLView generated .cs + migration SQL--create-only flag, then reviewReview generated .sql file
Auto-generate rollbackYes — Down() methodNoNo
Data migration supportYes — custom C# in Up()/Down()Manual SQL added to migration fileManual SQL added to migration file
Seed commandHasData() in OnModelCreating (tied to migrations) or external scriptprisma db seedExternal script (ts-node seed.ts)
Reset dev DBdotnet ef database drop && dotnet ef database updateprisma migrate resetDrop manually + drizzle-kit migrate
Migration history table__EFMigrationsHistory_prisma_migrations__drizzle_migrations
Squash/baselineCustom — edit snapshot + MigrationAttributeprisma migrate resolve --appliedManual journal edit
Introspect existing DBScaffold-DbContextprisma db pulldrizzle-kit introspect

Rollback Strategies

This is the biggest conceptual gap between EF Core and both TypeScript ORMs. EF Core generates Down() methods. Neither Prisma nor Drizzle generate rollback SQL automatically.

Your rollback options in TypeScript ORM land:

Option 1: Database restore (safest for production)

Keep automated backups (Render, AWS RDS, and Supabase all do this by default). If a migration causes a production incident, restore to the pre-migration snapshot. This is simpler than you might expect for many failure modes.

Option 2: Write rollback SQL manually

Write a new forward migration that undoes the previous one. This is the recommended approach for non-destructive changes:

-- Migration 0005: added a column that turned out to be wrong
-- drizzle/0005_add_bad_column.sql
ALTER TABLE "orders" ADD COLUMN "bad_column" text;

-- Rollback by writing migration 0006
-- drizzle/0006_remove_bad_column.sql
ALTER TABLE "orders" DROP COLUMN "bad_column";

Option 3: Transactional DDL (PostgreSQL-specific)

PostgreSQL supports DDL inside transactions. Drizzle and Prisma both run migrations in transactions by default. If your migration fails partway through, PostgreSQL rolls back the entire migration — you never end up with a partially applied state.

-- This runs inside a transaction; if the UPDATE fails, the ALTER TABLE rolls back too
ALTER TABLE "orders" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'pending';

UPDATE "orders"
SET "status" = CASE
    WHEN "shipped_at" IS NOT NULL THEN 'shipped'
    ELSE 'pending'
END;

This is a significant advantage over SQL Server for migration safety. The exception: operations like CREATE INDEX CONCURRENTLY cannot run inside a transaction and must be handled separately.

Option 4: Blue-green and expand-contract pattern

For zero-downtime deployments, use the expand-contract pattern:

Phase 1 (expand):   Add new column as nullable — deploy this migration, keep old code running
Phase 2 (migrate):  Backfill data in the new column — deploy code that writes to both columns
Phase 3 (contract): Make the column NOT NULL, drop the old column — deploy once backfill is complete

This avoids locking the table and allows rollback at each phase.


CI/CD Integration

GitHub Actions: Prisma

# .github/workflows/deploy.yml
name: Deploy

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'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Generate Prisma Client
        run: npx prisma generate

      - name: Run migrations
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

      - name: Deploy application
        run: |
          # your deployment command here
          npm run build && npm run start
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

GitHub Actions: Drizzle

name: Deploy

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'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run migrations
        run: npx drizzle-kit migrate
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

      - name: Deploy application
        run: npm run build && npm run start
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

Verifying Migration State in CI

Add a migration status check to your PR validation pipeline to catch drift early:

# PR validation job — does not deploy, just checks
validate-migrations:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - run: npm ci

    # Prisma: check that no migrations are pending
    - name: Check migration status
      run: |
        STATUS=$(npx prisma migrate status --json 2>/dev/null || echo '{"hasPendingMigrations": true}')
        echo $STATUS
      env:
        DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}

    # Drizzle: alternative — check that drizzle-kit generate produces no new files
    # (requires running against a staging DB that is up to date)

Gotchas for .NET Engineers

1. There is no auto-generated rollback — you must plan for it

EF Core’s Down() method creates an expectation that rollback is automatic and complete. In Prisma and Drizzle, you plan for rollback in advance:

  • Destructive changes (dropping columns, dropping tables) must be preceded by verifying the code no longer references them
  • Keep Down() SQL in a comment or companion file for reference
  • For production rollback, lean on database backups — they are reliable and fast
-- Prisma migration — include rollback SQL as a comment for reference
-- drizzle/0010_drop_legacy_column.sql

-- ROLLBACK (if needed): ALTER TABLE "orders" ADD COLUMN "legacy_field" text;

ALTER TABLE "orders" DROP COLUMN "legacy_field";

This is a convention you enforce, not a platform feature.

2. Never edit an applied migration file

This is the same rule as EF Core, but worth restating because the files are more tempting to edit (they are just SQL). If you edit a migration file that has already been applied to any database (dev, staging, production), you will cause a checksum mismatch.

Prisma computes a checksum of each migration file and stores it in _prisma_migrations. If the file changes after application, migrate deploy will error:

Error: Migration `20260115000000_add_status_to_orders` failed to apply cleanly to the shadow database.
Checksum verification failed.

If you need to change a migration that has not yet been deployed anywhere but yourself, delete the migration directory and regenerate. If it has been applied anywhere (even staging), write a new forward migration instead.

3. migrate dev vs migrate deploy — use the wrong one in production and you risk data loss

prisma migrate dev can trigger a database reset if it detects schema drift. Running it in production means it might drop your production database. This is not hypothetical — it has happened.

# Development only
npx prisma migrate dev         # interactive, may reset DB, regenerates client

# Production / CI only
npx prisma migrate deploy      # applies pending migrations, no prompts, no reset

Add a guard in your deployment scripts to prevent this:

#!/bin/bash
if [ "$NODE_ENV" = "production" ]; then
  npx prisma migrate deploy
else
  npx prisma migrate dev
fi

4. drizzle-kit push is not a migration tool

drizzle-kit push pushes your current schema directly to the database without creating migration files. It is intended for rapid prototyping on a throwaway database, similar to EF Core’s EnsureCreated().

Using push in any non-throwaway environment is dangerous: it may make destructive changes without a migration history, and you lose the ability to track what changed or reproduce the schema on a fresh database.

# Safe — prototyping only
npx drizzle-kit push

# Not safe — any environment where data matters
# Use generate + migrate instead
npx drizzle-kit generate && npx drizzle-kit migrate

5. Custom migration steps (data transforms, stored procedures) require manual SQL in Prisma/Drizzle

EF Core lets you write C# inside Up() and Down(). You can call services, use EF Core queries, or call stored procedures in a migration:

// EF Core — custom logic in migration
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AddColumn<string>("full_name", "users", "nvarchar(400)", nullable: true);

    // Call a custom stored procedure to backfill
    migrationBuilder.Sql("EXEC sp_BackfillFullName");

    // Or raw EF Core operations (via injected DbContext — requires custom IMigrationsSqlGenerator)
}

In Prisma and Drizzle, you add SQL directly to the migration file. If the logic is too complex for a SQL UPDATE statement, extract it to a PostgreSQL function and call it:

-- Prisma migration file — add your backfill SQL after the DDL
ALTER TABLE "users" ADD COLUMN "full_name" text;

-- Create a temporary function for the backfill
CREATE OR REPLACE FUNCTION backfill_full_name() RETURNS void AS $$
BEGIN
  UPDATE users
  SET full_name = first_name || ' ' || last_name
  WHERE full_name IS NULL;
END;
$$ LANGUAGE plpgsql;

SELECT backfill_full_name();

DROP FUNCTION backfill_full_name();

-- Now enforce NOT NULL after backfill
ALTER TABLE "users" ALTER COLUMN "full_name" SET NOT NULL;

This pattern — add nullable, backfill, then make NOT NULL — is the standard approach for adding required columns to populated tables without downtime.

6. The migration history table is in your database — production deployments need write access

prisma migrate deploy needs to read and write to _prisma_migrations. If your production database user has read-only permissions, migrations will fail. Create a dedicated migration user with the minimum permissions needed:

-- PostgreSQL: create a migration user
CREATE USER app_migrator WITH PASSWORD 'migration-password';
GRANT CONNECT ON DATABASE myapp TO app_migrator;
GRANT USAGE ON SCHEMA public TO app_migrator;
GRANT CREATE ON SCHEMA public TO app_migrator;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_migrator;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO app_migrator;

-- Application runtime user (read/write, no DDL)
CREATE USER app_runtime WITH PASSWORD 'runtime-password';
GRANT CONNECT ON DATABASE myapp TO app_runtime;
GRANT USAGE ON SCHEMA public TO app_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_runtime;
GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO app_runtime;

Use DATABASE_URL (the migrator user) only during migration steps in CI/CD, and DATABASE_URL (the runtime user) in the application itself. Keep them as separate secrets.


Hands-On Exercise

This exercise simulates a schema evolution across three migrations to practice the full Prisma workflow, including a data migration.

Starting schema (already exists in production):

model User {
  id        Int      @id @default(autoincrement())
  firstName String   @map("first_name")
  lastName  String   @map("last_name")
  email     String   @unique
  createdAt DateTime @default(now()) @map("created_at")

  @@map("users")
}

Migration 1: Add a fullName column and backfill it

Edit schema.prisma to add fullName:

model User {
  id        Int      @id @default(autoincrement())
  firstName String   @map("first_name")
  lastName  String   @map("last_name")
  fullName  String?  @map("full_name")   // nullable initially
  email     String   @unique
  createdAt DateTime @default(now()) @map("created_at")

  @@map("users")
}
npx prisma migrate dev --name add_full_name_column --create-only

The generated SQL will be:

ALTER TABLE "users" ADD COLUMN "full_name" TEXT;

Open the migration file and add the backfill:

ALTER TABLE "users" ADD COLUMN "full_name" TEXT;

-- Backfill existing rows
UPDATE "users" SET "full_name" = "first_name" || ' ' || "last_name";

Apply it:

npx prisma migrate dev

Migration 2: Make fullName required

Edit schema.prisma:

fullName  String   @map("full_name")   // remove the ?
npx prisma migrate dev --name make_full_name_required

Prisma generates:

ALTER TABLE "users" ALTER COLUMN "full_name" SET NOT NULL;

Migration 3: Add a role system

Add to schema.prisma:

model Role {
  id    Int    @id @default(autoincrement())
  name  String @unique
  users User[]

  @@map("roles")
}

model User {
  // ... existing fields
  roleId Int?   @map("role_id")
  role   Role?  @relation(fields: [roleId], references: [id])
}
npx prisma migrate dev --name add_roles --create-only

Edit the generated SQL to seed the default role and assign it to existing users:

-- Create the roles table
CREATE TABLE "roles" (
    "id" SERIAL NOT NULL,
    "name" TEXT NOT NULL,
    CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");

-- Add foreign key column to users
ALTER TABLE "users" ADD COLUMN "role_id" INTEGER;

-- Seed default roles
INSERT INTO "roles" ("name") VALUES ('user'), ('admin'), ('viewer');

-- Assign all existing users to the 'user' role
UPDATE "users" SET "role_id" = (SELECT "id" FROM "roles" WHERE "name" = 'user');

-- Add foreign key constraint
ALTER TABLE "users" ADD CONSTRAINT "users_role_id_fkey"
    FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
npx prisma migrate dev

Verify the state:

npx prisma migrate status

Quick Reference

# Prisma migration commands
npx prisma migrate dev --name <name>       # create + apply (development)
npx prisma migrate dev --create-only       # create SQL file, do not apply
npx prisma migrate dev                     # apply existing unapplied migrations
npx prisma migrate deploy                  # apply pending (production/CI)
npx prisma migrate status                  # show applied/pending migrations
npx prisma migrate reset                   # drop DB, re-apply all, seed (dev only)
npx prisma migrate resolve --applied <id>  # mark migration as applied (baseline)
npx prisma migrate resolve --rolled-back <id> # mark migration as rolled back
npx prisma db pull                         # introspect DB -> schema.prisma
npx prisma db push                         # push schema to DB without migrations (prototyping)
npx prisma db seed                         # run seed script
npx prisma generate                        # regenerate TypeScript client

# Drizzle migration commands
npx drizzle-kit generate                   # generate SQL migration from schema changes
npx drizzle-kit migrate                    # apply pending migrations
npx drizzle-kit push                       # push schema directly (prototyping only)
npx drizzle-kit introspect                 # generate schema.ts from existing DB
npx drizzle-kit studio                     # open data browser
npx drizzle-kit check                      # check for schema/migration inconsistencies
// Drizzle: programmatic migration (for startup scripts, tests)
import { migrate } from 'drizzle-orm/postgres-js/migrator';
await migrate(db, { migrationsFolder: './drizzle' });

// Prisma: check migration status programmatically
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Use $executeRaw to check _prisma_migrations if needed
Migration file locations:
  Prisma:  prisma/migrations/<timestamp>_<name>/migration.sql
  Drizzle: drizzle/<number>_<name>.sql
           drizzle/meta/_journal.json
           drizzle/meta/<number>_snapshot.json

Migration tracking tables:
  Prisma:  _prisma_migrations
  Drizzle: __drizzle_migrations
  EF Core: __EFMigrationsHistory

Production deployment checklist:

  • Run prisma migrate deploy (or drizzle-kit migrate) before starting new app version
  • Use the migrator database user (DDL permissions), not the runtime user
  • Keep database backups before any destructive migration
  • Test migration against a staging database first
  • Verify migration status after deployment
  • Never use migrate dev or db push in production
  • For large tables: test migration timing locally, consider CREATE INDEX CONCURRENTLY
  • For non-nullable column additions: backfill data in migration SQL before setting NOT NULL

Further Reading