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:
dotnet ef migrations add <Name>— generates the C# migration- Review and optionally edit the migration file
dotnet ef database update— applies to the target database- In production CI/CD:
dotnet ef database updateor 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:
- Computes the diff between your current
schema.prismaand the last migration - Writes a new
migration.sqlfile underprisma/migrations/<timestamp>_<name>/ - Applies the migration to your local development database
- Runs
prisma generateto 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:
- Create a new migration with the desired combined SQL (by hand or by running
db pushon a fresh DB then diffing) - Delete the old migration directories
- Mark the new baseline migration as applied on all existing databases with
migrate resolve --applied - 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 statusis 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:
- Decide which migration should be applied first
- Renumber if needed (Drizzle uses the journal for order, not file names)
- Update
_journal.jsonto reflect the correct order - Verify by running
drizzle-kit migrate --dry-runif 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
| Step | EF Core | Prisma | Drizzle |
|---|---|---|---|
| Schema source | C# entity classes | schema.prisma | TypeScript schema files |
| Create migration | dotnet ef migrations add <Name> | prisma migrate dev --name <name> | drizzle-kit generate |
| Apply to dev | dotnet ef database update | (automatic with migrate dev) | drizzle-kit migrate |
| Apply to production | dotnet ef database update or bundle | prisma migrate deploy | drizzle-kit migrate or programmatic |
| Preview SQL | View generated .cs + migration SQL | --create-only flag, then review | Review generated .sql file |
| Auto-generate rollback | Yes — Down() method | No | No |
| Data migration support | Yes — custom C# in Up()/Down() | Manual SQL added to migration file | Manual SQL added to migration file |
| Seed command | HasData() in OnModelCreating (tied to migrations) or external script | prisma db seed | External script (ts-node seed.ts) |
| Reset dev DB | dotnet ef database drop && dotnet ef database update | prisma migrate reset | Drop manually + drizzle-kit migrate |
| Migration history table | __EFMigrationsHistory | _prisma_migrations | __drizzle_migrations |
| Squash/baseline | Custom — edit snapshot + MigrationAttribute | prisma migrate resolve --applied | Manual journal edit |
| Introspect existing DB | Scaffold-DbContext | prisma db pull | drizzle-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(ordrizzle-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 devordb pushin 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