Skip to main content

Migrations

Safely evolve your database schema with version-controlled migrations and zero-downtime deployments.

Version Control

Track all schema changes

Auto-Generation

SQL from TypeScript schema

Zero Downtime

Safe deployment patterns

Rollback Support

Undo problematic changes

Migration Workflow

The standard workflow for making schema changes:

PropertyTypeDescription
drizzle-kit generatecommandGenerate SQL migrations from schema changes
drizzle-kit migratecommandApply pending migrations to the database
drizzle-kit pushcommandPush schema directly (dev only, no migration files)
drizzle-kit studiocommandOpen visual database browser
drizzle-kit checkcommandCheck migration consistency
drizzle-kit dropcommandDelete migration files
drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './src/lib/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    // Use unpooled connection for migrations
    url: process.env.DATABASE_URL_UNPOOLED!,
  },
  // Optional: customize migration table name
  migrations: {
    table: '__drizzle_migrations',
    schema: 'public',
  },
})

Use Unpooled Connection

Migrations require a direct database connection. Use DATABASE_URL_UNPOOLEDfor migrations to avoid connection pooler limitations.

Generating Migrations

After modifying your schema, generate a migration file:

1. Update Schema
// src/lib/db/schema.ts
import { pgTable, text, timestamp, uuid, boolean } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  name: text('name'),
  // NEW: Add a bio field
  bio: text('bio'),
  // NEW: Add email verification
  emailVerified: boolean('email_verified').default(false),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})
2. Generate Migration
# Generate migration SQL
npx drizzle-kit generate

# Output:
# [✓] Generated migration: 0001_add_bio_and_email_verified.sql
drizzle/0001_add_bio_and_email_verified.sql
-- Generated by Drizzle Kit
ALTER TABLE "users" ADD COLUMN "bio" text;
ALTER TABLE "users" ADD COLUMN "email_verified" boolean DEFAULT false;
3. Apply Migration
# Apply pending migrations
npx drizzle-kit migrate

# Output:
# [✓] Applied migration: 0001_add_bio_and_email_verified.sql
# [✓] Database schema is up to date

Development Workflow

For rapid iteration during development, use push instead of migrations:

Terminal
# Quick sync schema to database (development only!)
npx drizzle-kit push

# This directly applies schema changes without creating migration files
# WARNING: Don't use in production - changes aren't tracked

# Open Drizzle Studio to browse data
npx drizzle-kit studio
# Opens at https://local.drizzle.studio

Push vs Migrate

Use push for rapid development iteration. Use generate +migrate for production deployments to maintain a migration history.

Rolling Back Migrations

Drizzle Kit doesn't have automatic rollback. Create manual rollback migrations:

Manual Rollback Pattern
// Option 1: Create a new migration that reverts changes

// 1. Modify schema to remove the problematic changes
// 2. Run: npx drizzle-kit generate
// 3. This creates a migration that reverses the previous one

// Option 2: Manual SQL rollback
// Create a file: drizzle/0002_rollback_bio.sql
/*
ALTER TABLE "users" DROP COLUMN "bio";
ALTER TABLE "users" DROP COLUMN "email_verified";
*/
Using Database Branching for Safe Rollbacks
import { platform } from '@/lib/platform'

// Before deploying risky migrations, create a branch
const branch = await platform.database.createBranch({
  name: 'pre-migration-backup',
  parentBranch: 'main',
})

// Run migrations on main
// If something goes wrong, restore from branch

// Or use point-in-time recovery
await platform.database.restore({
  branch: 'main',
  timestamp: '2024-01-15T10:30:00Z', // Before the migration
})

Database Branching

Create a database branch before risky migrations. If something goes wrong, you can instantly restore from the branch or use point-in-time recovery.

Zero-Downtime Migrations

Strategies for deploying schema changes without downtime:

Adding Columns (Safe)
-- Safe: Adding a nullable column
ALTER TABLE "users" ADD COLUMN "bio" text;

-- Safe: Adding a column with default
ALTER TABLE "users" ADD COLUMN "status" text DEFAULT 'active';

-- Note: In PostgreSQL 11+, adding columns with defaults
-- doesn't rewrite the table, making it fast and safe
Removing Columns (Multi-Step)
// WRONG: Removing a column directly breaks running code
// ALTER TABLE "users" DROP COLUMN "old_field"; // DON'T DO THIS

// RIGHT: Three-phase deployment

// Phase 1: Deploy code that stops WRITING to old_field
// (old_field still exists in DB, reads still work)

// Phase 2: Deploy code that stops READING old_field
// (code no longer depends on old_field)

// Phase 3: Drop the column
// ALTER TABLE "users" DROP COLUMN "old_field";
Renaming Columns (Multi-Step)
// WRONG: Direct rename breaks running code
// ALTER TABLE "users" RENAME COLUMN "name" TO "full_name"; // DON'T DO THIS

// RIGHT: Add new, migrate data, remove old

// Phase 1: Add new column, write to both
/*
ALTER TABLE "users" ADD COLUMN "full_name" text;
UPDATE "users" SET "full_name" = "name";
*/

// Deploy code that writes to BOTH columns

// Phase 2: Backfill and switch reads
// Deploy code that reads from "full_name"

// Phase 3: Stop writing to old column

// Phase 4: Drop old column
/*
ALTER TABLE "users" DROP COLUMN "name";
*/

Adding Indexes Without Locking

Create indexes concurrently to avoid blocking writes:

drizzle/0003_add_search_index.sql
-- Standard index creation (BLOCKS writes during creation)
-- CREATE INDEX "posts_title_idx" ON "posts" ("title");

-- Concurrent index creation (NO blocking, but slower)
CREATE INDEX CONCURRENTLY "posts_title_idx" ON "posts" ("title");

-- Note: CONCURRENTLY can't run inside a transaction
-- Drizzle migrations run in transactions by default
-- You may need to run this manually or configure migration behavior
Manual Concurrent Index Creation
import { db } from '@/lib/db'
import { sql } from 'drizzle-orm'

// For large tables, create indexes outside of migrations
async function createSearchIndex() {
  // This runs outside a transaction
  await db.execute(sql`
    CREATE INDEX CONCURRENTLY IF NOT EXISTS "posts_search_idx"
    ON "posts" USING gin(to_tsvector('english', "title" || ' ' || "content"))
  `)
}

// Run this in a background job or deployment script

Concurrent Index Caveats

CREATE INDEX CONCURRENTLY can't run inside a transaction. It's also slower and can fail if there are conflicting operations. Always monitor index creation on production.

CI/CD Integration

Automate migrations in your deployment pipeline:

.github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run migrations
        env:
          DATABASE_URL_UNPOOLED: ${{ secrets.DATABASE_URL_UNPOOLED }}
        run: npx drizzle-kit migrate

      - name: Deploy application
        run: npm run deploy
Migration Check in PR
name: Check Migrations

on:
  pull_request:
    paths:
      - 'src/lib/db/schema.ts'
      - 'drizzle/**'

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Check migration consistency
        run: npx drizzle-kit check

      - name: Verify schema matches migrations
        run: |
          npx drizzle-kit generate --dry-run
          # Fails if there are ungenerated schema changes

Best Practices

Guidelines for safe schema evolution:

Make backward-compatible changes

Add nullable columns, new tables. Old code should work with new schema.

Test migrations on a branch first

Create a database branch, run migrations, verify everything works before production.

Deploy in phases

For breaking changes: add new, migrate data, update code, remove old.

Keep migrations small

One logical change per migration. Easier to review, test, and rollback.

Never edit applied migrations

Once a migration is in production, create a new migration for changes.

Migration Checklist

Before deploying migrations to production:

// Pre-deployment checklist:
// [ ] Migration tested on database branch
// [ ] Rollback plan documented
// [ ] Backup/snapshot created
// [ ] Off-peak deployment time scheduled (for large tables)
// [ ] Monitoring alerts configured
// [ ] Team notified of deployment

// Post-deployment verification:
// [ ] Migration completed without errors
// [ ] Application health checks passing
// [ ] No increase in error rates
// [ ] Query performance unchanged
// [ ] Rollback plan tested (optional)