Migration Basics
Migrations are version-controlled SQL scripts that modify your database schema. Drizzle generates these automatically by comparing your TypeScript schema with the current database state.Migration Configuration
The migration setup is defined indrizzle.config.ts:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/db/models/*', // Where your schema files are
out: './drizzle/migrations', // Where migrations are generated
dialect: 'postgresql', // Database dialect
dbCredentials: {
url: process.env.DATABASE_URL!
}
})Migration Files Structure
drizzle/
├── migrations/
│ ├── 0001_init_schema.sql
│ ├── 0002_add_user_avatar.sql
│ ├── 0003_create_projects_table.sql
│ └── meta/
│ ├── _journal.json
│ ├── 0001_snapshot.json
│ └── 0002_snapshot.json
└── schema.ts
Migration Development Workflow
Option 1: Push for Rapid Development
Usedb:push for quick schema changes during development:
1
Modify Schema
Edit your schema files in
/src/db/models/:// /src/db/models/user-model.ts
export const user = pgTable('user', {
id: uuid('id').default(sql`uuid_generate_v4()`).primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
// Add new field
avatar: text('avatar'),
bio: text('bio'),
})2
Push Changes
Apply changes directly to database:This will:
pnpm db:push- Compare your schema with database
- Apply changes immediately
- Skip migration generation
3
Verify Changes
Check that changes were applied:
pnpm db:studio
# Or check connection
pnpm db:checkDevelopment Only: Use
db:push only in development. Always use proper migrations for staging and production.Option 2: Generate Migrations
Use migrations for tracked, reversible changes:1
Modify Schema
Make your schema changes as above.
2
Generate Migration
Create migration files:This creates files like:
pnpm db:generate-- drizzle/migrations/0004_add_user_avatar.sql
ALTER TABLE "user" ADD COLUMN "avatar" text;
ALTER TABLE "user" ADD COLUMN "bio" text;3
Review Migration
Always review generated SQL before applying:
-- Check the generated migration file
cat drizzle/migrations/0004_add_user_avatar.sql4
Apply Migration
Run the migration:
pnpm db:migrateProduction Workflow
Safe Production Deployment
1
Test in Staging
Always test migrations in a staging environment first:
# Staging environment
NODE_ENV=staging pnpm db:migrate2
Backup Production
Create a backup before applying migrations:
# PostgreSQL backup
pg_dump $DATABASE_URL > backup_$(date +%Y%m%d_%H%M%S).sql3
Apply to Production
Run migrations in production:
NODE_ENV=production pnpm db:migrate4
Verify Deployment
Check that the migration succeeded:
NODE_ENV=production pnpm db:checkCommon Migration Scenarios
Adding Columns
// Schema change
export const user = pgTable('user', {
id: uuid('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
// Add optional field
phone: text('phone'), // nullable by default
})ALTER TABLE "user" ADD COLUMN "phone" text;Dropping Columns
Safe column removal with staging:// Step 1: Remove from schema
export const user = pgTable('user', {
id: uuid('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
// Remove: deprecated_field: text('deprecated_field'),
})-- Generated migration
ALTER TABLE "user" DROP COLUMN "deprecated_field";Destructive Operation: Dropping columns permanently deletes data. Always backup first and consider a multi-step process for critical data.
Creating New Tables
// Add new table to schema
export const notifications = pgTable('notifications', {
id: uuid('id').default(sql`uuid_generate_v4()`).primaryKey(),
userId: uuid('user_id').references(() => user.id).notNull(),
type: text('type').notNull(),
message: text('message').notNull(),
read: boolean('read').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
// Add relations
export const notificationRelations = relations(notifications, ({ one }) => ({
user: one(user, {
fields: [notifications.userId],
references: [user.id],
}),
}))CREATE TABLE "notifications" (
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
"user_id" uuid NOT NULL,
"type" text NOT NULL,
"message" text NOT NULL,
"read" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_user_id_fk"
FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;Modifying Existing Columns
// Change column type or constraints
export const user = pgTable('user', {
id: uuid('id').primaryKey(),
name: text('name').notNull(),
// Change: varchar(255) -> text, add unique constraint
email: text('email').notNull().unique(),
})ALTER TABLE "user" ALTER COLUMN "email" SET DATA TYPE text;
ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");Advanced Migration Patterns
Complex Data Migrations
Sometimes you need custom logic in migrations:1
Generate Base Migration
pnpm db:generate2
Edit Migration File
Add custom logic to the generated SQL:
-- drizzle/migrations/0005_complex_data_migration.sql
-- Generated schema changes
ALTER TABLE "projects" ADD COLUMN "slug" text;
-- Custom data migration
UPDATE "projects"
SET "slug" = LOWER(REPLACE("name", ' ', '-'))
WHERE "slug" IS NULL;
-- Add constraints after data migration
ALTER TABLE "projects" ALTER COLUMN "slug" SET NOT NULL;
ALTER TABLE "projects" ADD CONSTRAINT "projects_slug_unique" UNIQUE("slug");3
Test Migration
Test on a copy of production data:
# Restore backup to test database
psql $TEST_DATABASE_URL < production_backup.sql
# Test migration
NODE_ENV=test pnpm db:migrateMigration Rollbacks
Drizzle doesn't automatically generate rollback scripts, but you can create them manually:-- Migration: 0005_add_user_avatar.sql
ALTER TABLE "user" ADD COLUMN "avatar" text;
-- Rollback: 0005_rollback_add_user_avatar.sql (manual)
ALTER TABLE "user" DROP COLUMN "avatar";Zero-Downtime Migrations
For large tables, use techniques to avoid locking:-- Step 1: Add nullable column
ALTER TABLE "large_table" ADD COLUMN "new_field" text;
-- Step 2: Populate in batches (separate deployment)
UPDATE "large_table" SET "new_field" = 'default_value'
WHERE "new_field" IS NULL AND "id" BETWEEN 1 AND 1000;
-- Step 3: Make NOT NULL (separate deployment)
ALTER TABLE "large_table" ALTER COLUMN "new_field" SET NOT NULL;Migration Best Practices
Version Control
Always commit migration files:git add drizzle/migrations/
git commit -m "Add user avatar field migration"Testing Migrations
Create a migration testing script:// scripts/test-migration.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import postgres from 'postgres'
const testMigration = async () => {
const client = postgres(process.env.TEST_DATABASE_URL!)
const db = drizzle(client)
try {
await migrate(db, { migrationsFolder: './drizzle/migrations' })
console.log('✅ Migration test successful')
} catch (error) {
console.error('❌ Migration test failed:', error)
process.exit(1)
} finally {
await client.end()
}
}
testMigration()Migration Naming
Use descriptive names:# ✅ Good names
0001_initial_schema.sql
0002_add_user_avatar.sql
0003_create_projects_table.sql
0004_add_project_status_index.sql
# ❌ Avoid generic names
0001_migration.sql
0002_update.sql
0003_changes.sqlDocumentation
Document complex migrations:-- Migration: Add user avatar support
-- Date: 2024-01-15
-- Author: developer@example.com
-- Description: Adds avatar URL field to users table for profile pictures
-- Includes data migration to set default avatar for existing users
ALTER TABLE "user" ADD COLUMN "avatar" text;
-- Set default avatar for existing users
UPDATE "user"
SET "avatar" = 'https://api.dicebear.com/7.x/avataaars/svg?seed=' || "email"
WHERE "avatar" IS NULL;Troubleshooting Migrations
Common Issues
Migration fails with constraint error:# Error: column "email" of relation "user" already exists-- Add IF NOT EXISTS (PostgreSQL 9.6+)
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "email" text;
-- Or check in migration script
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'user' AND column_name = 'email'
) THEN
ALTER TABLE "user" ADD COLUMN "email" text;
END IF;
END $$;# Reset migration state (development only)
rm -rf drizzle/migrations
pnpm db:generate
pnpm db:push# Check migration status
SELECT * FROM __drizzle_migrations ORDER BY id;
# Manual rollback (if safe)
# Restore from backup and replay successful migrationsMigration Recovery
If a migration fails in production:1
Assess Damage
# Check database state
psql $DATABASE_URL -c "\d+ user"
# Check migration status
psql $DATABASE_URL -c "SELECT * FROM __drizzle_migrations;"2
Fix Forward
-- Create a fix migration instead of rolling back
-- Fix the issue that caused the failure
ALTER TABLE "user" DROP CONSTRAINT IF EXISTS "invalid_constraint";3
Mark as Complete
-- If migration partially succeeded, mark as complete
UPDATE __drizzle_migrations
SET applied_at = NOW()
WHERE id = 'failed_migration_id';A solid migration workflow is crucial for maintaining database integrity across environments. Always test migrations thoroughly before production deployment.
Next Steps
- Drizzle Studio - Use the database management GUI for development
- Best Practices - Learn performance and security best practices