Database migrations allow you to evolve your schema over time while preserving data. This guide covers the complete migration workflow from development to production.
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.
The migration setup is defined in drizzle.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!
}
})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
Use db:push for quick schema changes during development:
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'),
})Apply changes directly to database:
pnpm db:pushThis will:
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.
Use migrations for tracked, reversible changes:
Make your schema changes as above.
Create migration files:
pnpm db:generateThis creates files like:
-- drizzle/migrations/0004_add_user_avatar.sql
ALTER TABLE "user" ADD COLUMN "avatar" text;
ALTER TABLE "user" ADD COLUMN "bio" text;Always review generated SQL before applying:
-- Check the generated migration file
cat drizzle/migrations/0004_add_user_avatar.sqlRun the migration:
pnpm db:migrateAlways test migrations in a staging environment first:
# Staging environment
NODE_ENV=staging pnpm db:migrateCreate a backup before applying migrations:
# PostgreSQL backup
pg_dump $DATABASE_URL > backup_$(date +%Y%m%d_%H%M%S).sqlRun migrations in production:
NODE_ENV=production pnpm db:migrateCheck that the migration succeeded:
NODE_ENV=production pnpm db:check// 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
})Generated migration:
ALTER TABLE "user" ADD COLUMN "phone" text;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.
// 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],
}),
}))Generated migration:
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;// 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(),
})Generated migration:
ALTER TABLE "user" ALTER COLUMN "email" SET DATA TYPE text;
ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");Sometimes you need custom logic in migrations:
pnpm db:generateAdd 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");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:migrateDrizzle 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";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;Always commit migration files:
git add drizzle/migrations/
git commit -m "Add user avatar field migration"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()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.sqlDocument 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;Migration fails with constraint error:
# Error: column "email" of relation "user" already existsSolutions:
-- 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 $$;Migration state mismatch:
# Reset migration state (development only)
rm -rf drizzle/migrations
pnpm db:generate
pnpm db:pushProduction migration failure:
# Check migration status
SELECT * FROM __drizzle_migrations ORDER BY id;
# Manual rollback (if safe)
# Restore from backup and replay successful migrationsIf a migration fails in production:
# Check database state
psql $DATABASE_URL -c "\d+ user"
# Check migration status
psql $DATABASE_URL -c "SELECT * FROM __drizzle_migrations;"-- Create a fix migration instead of rolling back
-- Fix the issue that caused the failure
ALTER TABLE "user" DROP CONSTRAINT IF EXISTS "invalid_constraint";-- 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.