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-only

Reset Database#

# Reset and reseed (development only) npx prisma migrate reset

Production Migrations#

Deploy Migrations#

# Apply pending migrations npx prisma migrate deploy

CI/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 deploy

Safe 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_column

Adding 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 migration

Feature 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-point

Best Practices#

  1. Always backup before production migrations
  2. Test migrations on staging first
  3. Use transactions for multi-step migrations
  4. Create reversible migrations when possible
  5. Monitor database performance during migrations
  6. Run migrations during low-traffic periods
  7. Have a rollback plan ready