10+ AI SaaS templates for web & mobile
home

Database Migrations: Drizzle Guide for Production SaaS (2026)

·12 min read

What database migrations are, schema vs data migration, and a production Drizzle workflow: generate, migrate, push, CI/CD, rollbacks, squashing, and zero-downtime patterns.

If you searched database migrations, you probably need two things at once: a clear definition of what a migration is, and a workflow you can run in production without taking the app down. Search results mix schema migrations (DDL) with data migration guides from IBM and AWS that mean moving rows between systems. That is a different job.

This guide is for TypeScript teams shipping SaaS on PostgreSQL with Drizzle ORM and Drizzle Kit (what TurboStarter uses in Core Kit). You will see when to use drizzle-kit generate vs migrate vs push, how migration files and the journal work, how to run drizzle migrate in CI and on deploy, what Drizzle does and does not support for rollbacks, and practical patterns for additive changes and backfills. For ORM choice, read Drizzle vs Prisma instead of rehashing it here.

Short answer

Database migrations are versioned changes to your database structure (and sometimes data). In Drizzle's codebase-first flow, you edit TypeScript schema, run drizzle-kit generate to create SQL files, commit them, and apply with drizzle-kit migrate (CLI) or the programmatic migrate() API at runtime. Use push only for local prototyping. Drizzle Kit does not ship migrate down or automatic rollbacks; you recover with backups, forward-fix migrations, or manual SQL. TurboStarter Core runs pnpm with-env turbo db:generate and pnpm with-env pnpm --filter @workspace/db db:migrate; production can use the publish-db workflow or your deploy pipeline.

What are database migrations?

A database migration is a controlled, repeatable change to what the database stores and how it is shaped. In practice that usually means:

KindWhat changesExamples
Schema migration (DDL)Tables, columns, indexes, constraints, enumsCREATE TABLE, ALTER TABLE ... ADD COLUMN, CREATE INDEX
Data migration (DML)Row contents while structure stays compatibleBackfill a new column, split a name field, dedupe rows
Data migration (IT sense)Move data between systems or enginesPostgres to Snowflake, on-prem to cloud

When developers say "we need a migration," they almost always mean schema migrations plus optional data backfills in the same release. The IBM-style "data migration" articles rank for the same keyword but solve bulk transfer, not day-to-day schema evolution.

Why version them? Production has data you cannot lose. Migrations give you:

  • A git history of schema changes tied to application code
  • The same steps on every environment (local, staging, production)
  • Reviewable SQL in pull requests before it touches prod

TurboStarter treats the SQL files under packages/db/migrations/ as the contract between your Drizzle schema and every deployed instance.

How Drizzle fits: codebase-first migrations

Drizzle supports both database-first (drizzle-kit pull) and codebase-first (generate + migrate). TurboStarter is codebase-first: packages/db/src/schema is the source of truth, and Drizzle Kit diffs it against the last snapshot to emit SQL.

At a high level:

TypeScript schema  →  drizzle-kit generate  →  *.sql + meta snapshots
                              ↓
                    drizzle-kit migrate  →  PostgreSQL (+ __drizzle_migrations__)

Official overview: Drizzle migrations fundamentals.

TurboStarter pins drizzle-orm 0.41.0 and drizzle-kit 0.30.1 in @workspace/db. Commands below match that Kit version unless noted.

drizzle-kit generate vs migrate vs push

These three commands show up in almost every drizzle migrations thread. They are not interchangeable.

CommandWhat it doesWhen to use it
drizzle-kit generateDiff schema vs last snapshot; write new .sql and update meta/Every schema change you want in git
drizzle-kit migrateApply pending SQL files; record them in __drizzle_migrations__Local dev, CI, deploy hooks, release pipelines
drizzle-kit pushPush schema diff directly to the database without migration filesFast local prototyping only

generate: create migration files

From the repo root (loads .env via with-env):

pnpm with-env turbo db:generate

That runs @workspace/auth schema generation first (Better Auth tables), then drizzle-kit generate in packages/db. New files land in packages/db/migrations/, for example 0014_discount_claim_active_unique.sql, with matching entries in migrations/meta/_journal.json and snapshot JSON files.

Kit may prompt you to confirm renames vs drop-and-add. Answer carefully: wrong choices produce destructive SQL.

Other useful flags (see Drizzle Kit overview):

  • drizzle-kit generate --custom --name=seed-users for empty SQL you fill in (custom migrations)
  • drizzle-kit check to detect migration history collisions before merge

migrate: apply committed SQL

pnpm with-env pnpm --filter @workspace/db db:migrate

db:migrate is drizzle-kit migrate with credentials from packages/db/drizzle.config.ts (DATABASE_URL, PostgreSQL dialect, out: "./migrations"). Only unapplied files run. Re-running is safe.

For first-time local setup, pnpm services:setup starts Docker Postgres and runs migrations via db:setup.

push: skip files, sync schema directly

pnpm with-env pnpm --filter @workspace/db db:push

push is convenient when you are spiking tables locally. It does not replace migrations for teams or production. TurboStarter docs warn against using push outside local databases (migrations doc).

Rule of thumb: generate + migrate for anything merged to main; push when you are alone on a throwaway DB.

Migration files, journal, and snapshots

Understanding the folder layout saves you when Git conflicts or drift appear.

SQL files

Each migration is a .sql file with statements separated by Drizzle breakpoints (comments like --> statement-breakpoint). Example from TurboStarter's history:

CREATE TABLE IF NOT EXISTS "customers" (
  "userId" uuid PRIMARY KEY NOT NULL,
  ...
);

You can edit generated SQL when Kit cannot express your intent (partial indexes, concurrent index creation, custom constraints). Treat edits like any production DDL: review in PR, test on a copy of prod data.

meta/_journal.json

The journal lists migrations in order (idx, tag, when, version). drizzle-kit migrate uses this together with the database's __drizzle_migrations__ table to decide what ran.

Do not delete journal entries for migrations already applied in production unless you are executing a deliberate squash or recovery plan (see below).

Snapshot JSON

Files like meta/0014_snapshot.json are Kit's internal representation of schema at that point. You rarely edit them by hand. If Kit upgrades snapshot format, drizzle-kit up migrates snapshots (docs).

Running migrations in CI/CD and on deploy

Schema and code should move together. Pick a single apply step per environment and automate it.

GitHub Actions (TurboStarter Core)

The repo includes .github/workflows/publish-db.yml: db:check, then pnpm --filter @workspace/db db:migrate against DATABASE_URL / DEMO_DATABASE_URL secrets. The deployment checklist documents wiring secrets and running the workflow manually before launch.

Vercel and managed Postgres

Vercel does not run your migrations automatically. Common patterns:

  1. Migrate before deploy (recommended): run db:migrate in CI when packages/db/migrations/** changes, or trigger publish-db on release.
  2. Migrate on boot: use drizzle-orm's migrate() in a one-off release command or init container. Drizzle documents runtime migration in migrations fundamentals. Ensure only one leader runs migrations per deploy.

Connect string tips for Supabase: use the pooler URL for the app, direct URL for migrations if your provider requires it for DDL.

Docker / VPS

For self-hosted Next.js, run migrations before or during container start. TurboStarter's Docker guide pairs with:

pnpm with-env pnpm --filter @workspace/db db:migrate

Run that against the same DATABASE_URL the app will use, ideally as a separate init step so a failed migration does not leave half-started app processes.

Cloudflare Edge Kit (D1)

Edge Kit uses Wrangler-managed D1 migrations, not drizzle-kit migrate against Postgres. Local:

pnpm db:migrate --local

Deploy runs pnpm db:migrate --remote before wrangler deploy (Edge migrations). The same additive-change mindset applies: deploy schema before code that depends on it, or ship code that tolerates both shapes.

For Postgres behind Hyperdrive on Workers, keep using the Core db:migrate flow against the origin database; see deploy to Cloudflare Workers.

Rollbacks and down migrations (what Drizzle actually supports)

Drizzle Kit migrate applies forward only. There is no built-in drizzle-kit rollback or down migration generator like some frameworks provide.

ScenarioPractical response
Migration failed mid-deployFix SQL or schema, add a new forward migration; restore DB from backup if partially applied
Bad migration already in prodForward-fix (new migration) or restore backup; do not rewrite applied files
Need to undo a columnShip a later migration that drops it after code no longer reads it
EmergencyProvider backup / PITR (Neon, RDS, Supabase). Edge D1: Time Travel

Application rollback ≠ schema rollback. Rolling back a Vercel deployment to yesterday's JavaScript does not remove columns added today. Plan expand/contract (below) so old app versions keep working during deploy windows.

For custom repair SQL, use generate --custom and document the incident in the PR.

Squashing migrations (drizzle squash and workarounds)

Long-running products accumulate dozens of files. Teams ask about drizzle squash migrations; as of Drizzle Kit 0.30.x, there is no stable drizzle-kit squash command in the release TurboStarter pins (squash remains a requested feature with community workarounds).

Manual squash (only when every environment is caught up):

  1. Ensure all environments applied the latest migration.
  2. Archive or delete old migrations/*.sql and rebuild meta/ (or regenerate a single baseline from current schema).
  3. Align __drizzle_migrations__ in each database so Drizzle does not re-apply DDL on existing databases.

This is easy to get wrong. Prefer squashing before first production deploy, or accept a long migration chain until Kit ships official squash. Never squash away migrations that production has not applied.

drizzle-kit up is unrelated to squashing: it upgrades snapshot format after Kit major changes.

Database migration best practices for production

These patterns work with any SQL database; they matter more at scale and with zero-downtime deploys.

Prefer additive, backward-compatible changes

  1. Expand: add nullable column or new table; deploy code that writes both old and new shapes if needed.
  2. Migrate data: backfill in batches (background job or SQL UPDATE ... WHERE ... LIMIT).
  3. Contract: deploy code that reads only the new shape; later migration drops old column.

Avoid DROP COLUMN in the same release that introduces a replacement unless you are sure no old workers are running.

Indexes on large tables

Postgres blocks writes on CREATE INDEX (without CONCURRENTLY). For huge tables, hand-write:

CREATE INDEX CONCURRENTLY IF NOT EXISTS "user_email_idx" ON "user" ("email");

Kit may not emit CONCURRENTLY; use a custom migration file and run during a maintenance window or low traffic.

Multi-tenant and shared databases

If you use row-level tenancy, keep tenant isolation in application code and constraints, not ad hoc migration edits. See multi-tenant SaaS for schema patterns; migrations should still be global DDL.

Team workflow and merge conflicts

  • One migration per PR when possible; rebase and regenerate if main added a migration with a lower number than yours.
  • Never change SQL that already shipped to production; add a new file instead.
  • Run pnpm --filter @workspace/db db:check before merge (same as CI).
  • After switching branches locally, if your database has the wrong tables, reset the local Docker volume or use db:reset rather than fighting journal drift.

Review checklist

  • SQL read in PR, not only TypeScript schema
  • Backfill plan for NOT NULL columns
  • Deploy order documented (migrate then app, or compatible dual-read)
  • Secrets and DATABASE_URL point at the intended environment

TurboStarter commands cheat sheet

GoalCommand
Generate SQL from schemapnpm with-env turbo db:generate
Apply migrations (Postgres)pnpm with-env pnpm --filter @workspace/db db:migrate
Local Docker + migratepnpm services:setup
Prototype schema (local only)pnpm with-env pnpm --filter @workspace/db db:push
Collision checkpnpm --filter @workspace/db db:check
Inspect datapnpm with-env pnpm --filter @workspace/db db:studio

Deeper reference: Core migrations docs. Edge D1: Edge migrations.

FAQ

Sounds good?Now let's make it real. In minutes.
Try TurboStarter

Database migrations are how a production-ready SaaS keeps Postgres aligned with your TypeScript schema across web, mobile, extension, and AI surfaces that share one backend. Drizzle Kit keeps the loop small: generate, review SQL, migrate, ship. TurboStarter wires the scripts, Docker Postgres, and GitHub workflow so you own the process instead of guessing from outdated forum posts. If you want the full kit, see pricing or start from TurboStarter.

world map
Community

Connect with like-minded people

Join our community to get feedback, support, and grow together with 1,000+ builders on board, let's ship it!

Join us

Ship your startup everywhere. In minutes.

Don't burn tokens on setup and start building features on day one.

Get TurboStarter