How to Write Database Migrations
This guide covers the complete Drizzle ORM migration workflow: making schema changes, generating migration files, running migrations, handling rollbacks, and seeding data.
Prerequisites
- PostgreSQL database running and
DATABASE_URLconfigured in.env.local - Drizzle Kit installed (included in project dependencies)
- Familiarity with the Drizzle ORM schema definition API
- Understanding of
lib/db/schema.ts
Architecture Overview
The database layer is organized as follows:
lib/db/
schema.ts # All table definitions (Drizzle ORM)
migrations/
schema.ts # Auto-generated schema snapshot
relations.ts # Auto-generated relation definitions
meta/ # Drizzle Kit metadata (journal, snapshots)
0000_burly_darkstar.sql
0001_add_image_to_users.sql
... # Sequential migration files
drizzle.config.ts # Drizzle Kit configuration
The drizzle.config.ts points to:
- schema:
./lib/db/schema.ts - out:
./lib/db/migrations - dialect:
postgresql
Step 1: Modify the Schema
Open lib/db/schema.ts and make your changes. Drizzle Kit will diff the schema against the last snapshot to generate the migration.
Adding a New Table
// lib/db/schema.ts
export const coupons = pgTable(
'coupons',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
code: varchar('code', { length: 50 }).notNull().unique(),
discountPercent: integer('discount_percent').notNull(),
maxUses: integer('max_uses'),
usageCount: integer('usage_count').notNull().default(0),
status: text('status', { enum: ['active', 'expired', 'disabled'] })
.notNull()
.default('active'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
codeIndex: uniqueIndex('coupons_code_unique').on(table.code),
statusIndex: index('coupons_status_idx').on(table.status),
createdAtIndex: index('coupons_created_at_idx').on(table.createdAt),
}),
);
Adding a Column to an Existing Table
// Add to the existing users table definition
export const users = pgTable('users', {
// ... existing columns ...
avatarUrl: text('avatar_url'), // New column
});
Adding a Foreign Key Relationship
export const couponUsages = pgTable(
'coupon_usages',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
couponId: text('coupon_id')
.notNull()
.references(() => coupons.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
usedAt: timestamp('used_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
couponUserUnique: uniqueIndex('coupon_usages_coupon_user_unique').on(
table.couponId,
table.userId,
),
couponIndex: index('coupon_usages_coupon_idx').on(table.couponId),
userIndex: index('coupon_usages_user_idx').on(table.userId),
}),
);
Step 2: Generate the Migration
Run the Drizzle Kit generate command:
pnpm db:generate
This compares your current schema.ts against the last snapshot in lib/db/migrations/meta/ and produces a new SQL file, for example:
lib/db/migrations/0029_add_coupons.sql
Step 3: Review the Generated SQL
Always review the generated SQL before running it. Open the file and verify:
-- lib/db/migrations/0029_add_coupons.sql
CREATE TABLE IF NOT EXISTS "coupons" (
"id" text PRIMARY KEY NOT NULL,
"code" varchar(50) NOT NULL,
"discount_percent" integer NOT NULL,
"max_uses" integer,
"usage_count" integer DEFAULT 0 NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "coupons_code_unique" UNIQUE("code")
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "coupons_code_unique" ON "coupons" USING btree ("code");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "coupons_status_idx" ON "coupons" USING btree ("status");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "coupons_created_at_idx" ON "coupons" USING btree ("created_at");
Things to check:
- Column types match your intentions
NOT NULLconstraints are correct- Default values are sensible
- Indexes are on the right columns
- Foreign keys reference the correct tables
Step 4: Run the Migration
Apply the migration to your database:
pnpm db:migrate
This executes all pending migration files in order against the database specified by DATABASE_URL.
Step 5: Verify the Migration
Use Drizzle Studio to inspect the database:
pnpm db:studio
This opens a web UI where you can browse tables, inspect data, and verify the schema looks correct.