Migrations
Schema-first. Generate SQL artifacts, review, commit, apply.
The cardinal rule
Never run
drizzle-kit pushagainst any non-local DB.
push compares the schema to the target DB and applies the diff with no SQL artifact. It's the canonical horror story for prod data loss. Always go through generate → review → migrate.
Workflow
# 1. Edit schema
$EDITOR src/db/schema/business.ts
# 2. Generate SQL
pnpm db:generate --name add_my_column
# → drizzle/0017_add_my_column.sql
# 3. REVIEW THE GENERATED FILE
$EDITOR drizzle/0017_add_my_column.sql
# 4. Commit
git add src/db/schema/ drizzle/
git commit -m "Add my_column to agents"
# 5. Apply
pnpm db:migrate # dev (uses ts-node)
pnpm db:migrate:prod # prod (compiled, in Docker)
Auto-generated auth schema
Don't hand-edit src/db/schema/auth.ts. After upgrading better-auth or a plugin:
pnpm db:auth-schema # runs @better-auth/cli generate
pnpm db:generate --name auth_upgrade
# review + migrate
The auth.ts file has a frontmatter comment saying this. If a PR touches it by hand, reject.
What generate emits
drizzle-kit generate produces:
drizzle/NNNN_<slug>.sql— the migration SQL.drizzle/meta/_journal.json— registry of applied migrations.drizzle/meta/NNNN_snapshot.json— schema snapshot for diffing.
Each statement is separated by --> statement-breakpoint so the migrator can run them one at a time.
Rare patterns
Drizzle handles most cases automatically. For the rare cases (partial indexes, CHECK constraints, GENERATED columns), express them in TypeScript with sql\...``:
.where(sql`status IN ('active', 'trialing')`)
These come through generate correctly.
Standalone migrator
src/db/migrate.ts is a standalone Node script:
- Loads
.envitself. - Resolves
drizzle/viaprocess.cwd(). - Works under both ts-node (dev) and
node dist/src/db/migrate.js(prod).
docker-entrypoint.sh runs it on every container start. Migrations are tracked in __drizzle_migrations; re-running an applied migration is a no-op.
Rollback
There is no automatic down migration. If you need to undo:
- Write a new migration that reverses the change.
- Generate and apply it like any forward migration.
This is intentional. Drizzle's stance is "down migrations are dangerous in production"; just write a fix-forward migration.
Common patterns
Add a non-null column with default
isArchived: boolean("is_archived").notNull().default(false)
Generates ALTER TABLE ... ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT false; — safe on existing rows.
Add a non-null column with no default
Two-step migration:
- Add as nullable.
- Backfill in a follow-up migration.
- ALTER to set NOT NULL once backfill is verified.
Rename a column
Drizzle treats column renames as DROP + ADD by default. Use .rename or write a manual ALTER TABLE ... RENAME COLUMN ... migration to preserve data.
Add a unique constraint
uniqueIndex("agent_pricing_tiers_unique_active")
.on(table.agentId, table.tierName)
Verify before deploy
pnpm db:migrate:dry # if you've added a dry-run flag
psql -1 -f drizzle/0017_add_my_column.sql $DATABASE_URL # one-shot transaction
Drizzle Studio
pnpm db:studio # opens at https://local.drizzle.studio
Useful for inspecting rows and verifying migration effects without dropping into psql.