Database Migrations
Safe migration strategies for production databases.
Development Migrations#
Create Migration#
# Create a new migration
npx prisma migrate dev --name add_user_role
# Create migration without applying
npx prisma migrate dev --create-onlyReset Database#
# Reset and reseed (development only)
npx prisma migrate resetProduction Migrations#
Deploy Migrations#
# Apply pending migrations
npx prisma migrate deployCI/CD Integration#
1# .github/workflows/deploy.yml
2name: Deploy
3
4on:
5 push:
6 branches: [main]
7
8jobs:
9 migrate:
10 runs-on: ubuntu-latest
11 steps:
12 - uses: actions/checkout@v4
13
14 - name: Setup Node.js
15 uses: actions/setup-node@v4
16 with:
17 node-version: '20'
18
19 - name: Install dependencies
20 run: npm ci
21
22 - name: Run migrations
23 env:
24 DATABASE_URL: ${{ secrets.DATABASE_URL }}
25 run: npx prisma migrate deploySafe Migration Patterns#
Adding a Column#
1// Step 1: Add nullable column
2model User {
3 id String @id
4 email String
5 phone String? // New nullable column
6}npx prisma migrate dev --name add_phone_columnAdding Required Column with Default#
1// Add with default value
2model User {
3 id String @id
4 email String
5 createdAt DateTime @default(now()) // New with default
6}Renaming a Column#
-- migrations/xxx_rename_column/migration.sql
-- Prisma doesn't auto-detect renames, create manually
ALTER TABLE "User" RENAME COLUMN "name" TO "fullName";// Update schema to match
model User {
id String @id
fullName String? // Renamed from name
}Adding an Index#
1model Post {
2 id String @id
3 slug String
4 authorId String
5 createdAt DateTime @default(now())
6
7 // Add indexes
8 @@index([slug])
9 @@index([authorId, createdAt])
10}Changing Column Type#
1-- migrations/xxx_change_type/migration.sql
2-- Two-step approach for safety
3
4-- Step 1: Add new column
5ALTER TABLE "Post" ADD COLUMN "views_new" BIGINT DEFAULT 0;
6
7-- Step 2: Migrate data
8UPDATE "Post" SET "views_new" = "views"::BIGINT;
9
10-- Step 3: Drop old, rename new
11ALTER TABLE "Post" DROP COLUMN "views";
12ALTER TABLE "Post" RENAME COLUMN "views_new" TO "views";Zero-Downtime Migrations#
Expand-Contract Pattern#
1// Phase 1: Expand (add new column)
2// migrations/001_add_new_column/migration.sql
3ALTER TABLE "User" ADD COLUMN "email_new" TEXT;
4
5// Phase 2: Migrate data (application code)
6// scripts/migrate-emails.ts
7const users = await prisma.user.findMany({
8 where: { email_new: null },
9 take: 1000,
10});
11
12for (const user of users) {
13 await prisma.user.update({
14 where: { id: user.id },
15 data: { email_new: normalizeEmail(user.email) },
16 });
17}
18
19// Phase 3: Contract (remove old column)
20// migrations/002_remove_old_column/migration.sql
21ALTER TABLE "User" DROP COLUMN "email";
22ALTER TABLE "User" RENAME COLUMN "email_new" TO "email";Dual-Write Pattern#
1// lib/services/user-service.ts
2export async function updateUserEmail(userId: string, email: string) {
3 // Write to both old and new columns during transition
4 await prisma.user.update({
5 where: { id: userId },
6 data: {
7 email, // Old column
8 email_v2: email, // New column
9 },
10 });
11}Handling Breaking Changes#
Deprecate Before Remove#
1// Step 1: Mark as deprecated in code
2interface User {
3 id: string;
4 /** @deprecated Use fullName instead */
5 name?: string;
6 fullName: string;
7}
8
9// Step 2: Populate new field
10await prisma.user.updateMany({
11 where: { fullName: null },
12 data: { fullName: prisma.user.fields.name },
13});
14
15// Step 3: Remove old field after full migrationFeature Flags for Migrations#
1// lib/features.ts
2export const FEATURES = {
3 USE_NEW_EMAIL_FIELD: process.env.USE_NEW_EMAIL_FIELD === 'true',
4};
5
6// In your code
7function getUserEmail(user: User): string {
8 if (FEATURES.USE_NEW_EMAIL_FIELD) {
9 return user.emailNormalized;
10 }
11 return user.email;
12}Migration Scripts#
Batch Data Migration#
1// scripts/migrate-data.ts
2import { prisma } from '@/lib/prisma';
3
4const BATCH_SIZE = 1000;
5
6async function migrateUsers() {
7 let processed = 0;
8 let hasMore = true;
9
10 while (hasMore) {
11 const users = await prisma.user.findMany({
12 where: {
13 migratedAt: null,
14 },
15 take: BATCH_SIZE,
16 });
17
18 if (users.length === 0) {
19 hasMore = false;
20 break;
21 }
22
23 await prisma.$transaction(
24 users.map((user) =>
25 prisma.user.update({
26 where: { id: user.id },
27 data: {
28 fullName: `${user.firstName} ${user.lastName}`,
29 migratedAt: new Date(),
30 },
31 })
32 )
33 );
34
35 processed += users.length;
36 console.log(`Migrated ${processed} users`);
37
38 // Small delay to reduce database load
39 await new Promise((resolve) => setTimeout(resolve, 100));
40 }
41
42 console.log(`Migration complete: ${processed} users migrated`);
43}
44
45migrateUsers().catch(console.error);Verify Migration#
1// scripts/verify-migration.ts
2import { prisma } from '@/lib/prisma';
3
4async function verifyMigration() {
5 const unmigrated = await prisma.user.count({
6 where: { migratedAt: null },
7 });
8
9 const nullValues = await prisma.user.count({
10 where: { fullName: null },
11 });
12
13 console.log(`Unmigrated users: ${unmigrated}`);
14 console.log(`Users with null fullName: ${nullValues}`);
15
16 if (unmigrated > 0 || nullValues > 0) {
17 console.error('Migration incomplete!');
18 process.exit(1);
19 }
20
21 console.log('Migration verified successfully');
22}
23
24verifyMigration().catch(console.error);Rollback Strategies#
Automatic Rollback Script#
-- migrations/xxx_add_feature/down.sql
-- Create a down migration for manual rollback
DROP INDEX IF EXISTS "Post_slug_idx";
ALTER TABLE "Post" DROP COLUMN IF EXISTS "views";Point-in-Time Recovery#
# For Neon
neonctl branches create --name rollback-point
# Restore to branch if needed
neonctl branches restore --source rollback-pointBest Practices#
- Always backup before production migrations
- Test migrations on staging first
- Use transactions for multi-step migrations
- Create reversible migrations when possible
- Monitor database performance during migrations
- Run migrations during low-traffic periods
- Have a rollback plan ready