description: Example of creating and applying database migrations version: 1.0.0 lastUpdated: 2025-01-10 tags: [example, database, migration, drizzle]
Example: Database Migration
This document demonstrates how to create and apply database schema changes safely.
Scenario 1: Adding a New Column
Step 1: Modify Schema
// File: packages/database/src/schema.ts
import { ulid } from 'ulid';
export const projects = pgTable('projects', {
id: varchar('id', { length: 191 }).primaryKey(), // ULID stored as varchar
name: text('name').notNull(),
description: text('description'),
// ✨ NEW COLUMN
githubUrl: text('github_url'),
userId: varchar('user_id', { length: 191 }).references(() => users.id).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
// When inserting, generate ULID
const projectId = ulid();
await db.insert(projects).values({
id: projectId,
userId: ctx.user.id,
// ...
});
Step 2: Generate Migration
cd packages/database
pnpm db:generate
Generated SQL (migrations/0001_add_github_url.sql):
-- Migration: Add github_url column to projects table
ALTER TABLE "projects" ADD COLUMN "github_url" text;
Step 3: Review Migration
# Check the generated SQL file
cat migrations/0001_add_github_url.sql
# Verify it matches expectations:
# - Column name correct
# - Type correct
# - Nullable/Not null correct
# - No unexpected changes
Step 4: Apply Migration
# Development
pnpm db:migrate
# Production (with backup first!)
# 1. Backup database
pg_dump -U postgres dbname > backup.sql
# 2. Apply migration
pnpm db:migrate
Step 5: Verify
# Open Drizzle Studio
pnpm db:studio
# Or check directly in PostgreSQL
psql -U postgres -d dbname -c "\d projects"
Scenario 2: Adding a New Table with Relations
Step 1: Define Schema
// File: packages/database/src/schema.ts
import { ulid } from 'ulid';
// New table: webhooks
export const webhooks = pgTable('webhooks', {
id: varchar('id', { length: 191 }).primaryKey(), // ULID stored as varchar
url: text('url').notNull(),
secret: text('secret').notNull(),
events: text('events').array().notNull(), // Array of event types
projectId: varchar('project_id', { length: 191 })
.references(() => projects.id, { onDelete: 'cascade' })
.notNull(),
isActive: boolean('is_active').default(true).notNull(),
lastTriggeredAt: timestamp('last_triggered_at'),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (table) => ({
projectIdIdx: index('webhooks_project_id_idx').on(table.projectId),
}));
// Relations
export const webhooksRelations = relations(webhooks, ({ one }) => ({
project: one(projects, {
fields: [webhooks.projectId],
references: [projects.id],
}),
}));
// Update projects relations
export const projectsRelations = relations(projects, ({ many }) => ({
webhooks: many(webhooks),
// ... other relations
}));
// Types
export type Webhook = InferSelectModel<typeof webhooks>;
export type NewWebhook = InferInsertModel<typeof webhooks>;
Step 2: Generate Migration
pnpm db:generate
Generated SQL:
-- Create webhooks table
CREATE TABLE IF NOT EXISTS "webhooks" (
"id" varchar(191) PRIMARY KEY, -- ULID stored as varchar
"url" text NOT NULL,
"secret" text NOT NULL,
"events" text[] NOT NULL,
"project_id" varchar(191) NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"last_triggered_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
-- Create foreign key
ALTER TABLE "webhooks" ADD CONSTRAINT "webhooks_project_id_projects_id_fk"
FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE;
-- Create index
CREATE INDEX IF NOT EXISTS "webhooks_project_id_idx" ON "webhooks" ("project_id");
Step 3: Apply and Verify
pnpm db:migrate
pnpm db:studio
Scenario 3: Renaming a Column (Breaking Change)
⚠️ Caution: This is a breaking change that requires coordination.
Option A: Direct Rename (Requires Downtime)
-- Manual migration: migrations/XXXX_rename_column.sql
ALTER TABLE "projects" RENAME COLUMN "github_url" TO "repository_url";
Option B: Gradual Migration (No Downtime)
// Step 1: Add new column
export const projects = pgTable('projects', {
// ... existing columns
githubUrl: text('github_url'), // Keep old column
repositoryUrl: text('repository_url'), // Add new column
});
-- Migration 1: Add new column
ALTER TABLE "projects" ADD COLUMN "repository_url" text;
-- Migration 2: Copy data
UPDATE "projects" SET "repository_url" = "github_url" WHERE "github_url" IS NOT NULL;
// Step 2: Update code to use new column
// Update all references in codebase
// Step 3: After deployment, remove old column
export const projects = pgTable('projects', {
// ... existing columns
repositoryUrl: text('repository_url'),
// githubUrl removed
});
-- Migration 3: Drop old column
ALTER TABLE "projects" DROP COLUMN "github_url";
Scenario 4: Adding NOT NULL Constraint to Existing Column
⚠️ Careful: This can fail if existing rows have NULL values.
Safe Approach
-- Step 1: Backfill NULL values
UPDATE "projects" SET "description" = '' WHERE "description" IS NULL;
-- Step 2: Add NOT NULL constraint
ALTER TABLE "projects" ALTER COLUMN "description" SET NOT NULL;
-- Step 3: Optionally set default for future inserts
ALTER TABLE "projects" ALTER COLUMN "description" SET DEFAULT '';
In Drizzle Schema
export const projects = pgTable('projects', {
// ... other columns
description: text('description').notNull().default(''),
});
Scenario 5: Adding Indexes for Performance
Identify Need
-- Slow query
SELECT * FROM "projects" WHERE "user_id" = '...' ORDER BY "created_at" DESC;
-- Check query plan
EXPLAIN ANALYZE SELECT * FROM "projects" WHERE "user_id" = '...' ORDER BY "created_at" DESC;
Add Index
// File: packages/database/src/schema.ts
export const projects = pgTable('projects', {
// ... columns
}, (table) => ({
userIdCreatedAtIdx: index('projects_user_id_created_at_idx')
.on(table.userId, table.createdAt.desc()),
}));
Generate and Apply
pnpm db:generate
pnpm db:migrate
Generated SQL:
CREATE INDEX IF NOT EXISTS "projects_user_id_created_at_idx"
ON "projects" ("user_id", "created_at" DESC);
Best Practices
✅ Do
- Always review generated SQL before applying
- Backup production database before migrations
- Test migrations in development first
- Use transactions for complex migrations
- Add indexes for frequently queried columns
- Use cascading deletes where appropriate
- Document breaking changes in migration files
❌ Don't
- Never manually edit schema.ts without generating migration
- Don't drop columns without a deprecation period
- Don't add NOT NULL without backfilling data
- Don't skip migration review
- Don't apply untested migrations to production
- Don't create indexes blindly (monitor query performance first)
Common Migration Patterns
Pattern 1: Add Column with Default
newColumn: text('new_column').notNull().default('default_value'),
Pattern 2: Foreign Key with Cascade
foreignId: varchar('foreign_id', { length: 191 }) // ULID stored as varchar
.references(() => otherTable.id, { onDelete: 'cascade' })
.notNull(),
Pattern 3: Composite Index
}, (table) => ({
compositeIdx: index('table_col1_col2_idx').on(table.col1, table.col2),
}));
Pattern 4: Unique Constraint
email: text('email').notNull().unique(),
// Or composite unique
}, (table) => ({
uniqueNameProject: unique('unique_name_per_project').on(table.name, table.projectId),
}));
Pattern 5: Check Constraint
import { check } from 'drizzle-orm/pg-core';
}, (table) => ({
checkPositivePrice: check('price_positive', sql`price > 0`),
}));
Rollback Strategy
Automatic Rollback (Transaction)
// Drizzle migrations run in a transaction by default
// If any part fails, entire migration is rolled back
Manual Rollback
-- Create rollback migration
-- migrations/XXXX_rollback_add_column.sql
ALTER TABLE "projects" DROP COLUMN "github_url";
Emergency Rollback
# Restore from backup
pg_restore -U postgres -d dbname backup.sql
# Or use point-in-time recovery (cloud providers)
Testing Migrations
// File: packages/database/src/__tests__/migrations.test.ts
import { describe, it, expect } from 'vitest';
import { db } from '../client';
import { projects } from '../schema';
describe('migrations', () => {
it('should have applied all migrations', async () => {
// Test that schema is correct
const result = await db.select().from(projects).limit(1);
expect(result).toBeDefined();
});
it('should allow inserting data with new schema', async () => {
const [project] = await db.insert(projects).values({
name: 'Test Project',
description: 'Test description',
userId: 'test-user-id',
githubUrl: 'https://github.com/test/repo', // New column
}).returning();
expect(project.githubUrl).toBe('https://github.com/test/repo');
});
});
Summary
Migration Workflow:
- Modify
schema.ts - Run
pnpm db:generate - Review generated SQL
- Test in development
- Backup production
- Apply with
pnpm db:migrate - Verify in Drizzle Studio
Safety Checklist:
- Reviewed generated SQL
- Tested in development
- Considered backward compatibility
- Backed up production database
- Planned rollback strategy
- Updated TypeScript types
- Updated code references
- Documented breaking changes
Version: 1.0.0
Last Updated: 2025-01-10