Drizzle ORM gives you two migration approaches, and the docs don't clearly tell you when each one is the right call. I've been running schema-first Drizzle migrations in production across two of my three SvelteKit starter kits — one on SQLite/D1, one on vendor-neutral Postgres — and I've landed on a decision rule that stopped burning me. The third kit (Supabase) doesn't use Drizzle at all, and the contrast turned out to be instructive. Here it is, plus the code I actually use.
The two approaches
Code-first with generated migrations (drizzle-kit generate + drizzle-kit migrate):
# Edit src/lib/server/db/schema.ts, then:
npx drizzle-kit generate # writes a versioned .sql file + journal metadata
npx drizzle-kit migrate # applies pending migrations to the database
generate takes a snapshot of your schema, diffs it against the last one Drizzle recorded, and writes a plain SQL file you can read, review, and commit. migrate replays the pending files in order and records what ran.
Push (drizzle-kit push):
npx drizzle-kit push
push diffs your schema directly against a live database and applies whatever it takes to make them match — no migration files, no history.
Why push is fine in development but not in production
Push is great during prototyping: edit schema.ts, run push, the database matches, done. Three things make it the wrong tool once other people (or real data) depend on that database:
No audit trail. There's no record of what changed or when. When something breaks at 3 AM, you can't point at "the last migration that ran" — there isn't one.
Destructive changes are silent.
pushwill happily drop columns, rename tables, and delete indexes that aren't in your schema anymore. In development that's fine; you'd recreate the database anyway. In production, that deletes real data without a review step.No escape hatch. Generated migrations are plain SQL — you can read them, edit them, and add hand-written statements before or after the generated ones. If a
pushbreaks something, your only tool is writing manual SQL from scratch.
Schema-first in production
The config is small:
// drizzle.config.ts
export default {
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
dialect: 'postgresql', // 'sqlite' for the D1 kit
dbCredentials: {
// drizzle-kit loads .env; the fallback keeps `docker compose up` working with no setup
url: process.env.DATABASE_URL || 'postgres://user:pass@localhost:5433/db',
},
} satisfies import('drizzle-kit').Config;
And the workflow never varies:
# 1. Edit schema.ts
# 2. Generate the migration SQL
npx drizzle-kit generate
# 3. Review the generated SQL — always read it (this is where typos get caught)
# 4. Apply it (or via your platform's migration tool — see D1 below)
npx drizzle-kit migrate
The key insight: generated migrations are the escape hatch that push doesn't give you. Need a backfill, a partial index, or a data fix that doesn't belong in schema.ts? Open the generated file, add it, apply it. The migration history becomes the source of truth for how the database evolved.
The auto-migrate-at-boot pattern
For apps where you control the deployment, you can run migrations when the database connection is created instead of relying on a separate CI step alone. This is where my Postgres kit does it — inside createDb(), not in hooks.server.ts:
// src/lib/server/db/index.ts
import { migrate } from 'drizzle-orm/postgres-js/migrator';
export async function createDb(url: string) {
const client = postgres(url);
const db = drizzle(client, { schema });
await migrate(db, { migrationsFolder: MIGRATIONS_DIR }); // <-- runs at boot
return db;
}
It ships alongside an explicit script, and my deploy docs still list that script as a step:
npm run db:migrate # tsx scripts/migrate.ts
Both is the point, not a contradiction. The explicit step means a deploy is a reviewed, ordered event you can watch. The boot-time call is a safety net: it guarantees no instance ever serves traffic against an unmigrated schema, which matters most for a preview deploy or a replica that came up unexpectedly.
Where the safety net stops being safe: concurrent deploys against one database. Two instances booting at the same time can both decide a migration is pending, and you get column already exists — or worse, a partially applied migration. If you run multiple replicas, keep the explicit pre-deploy step as the only writer and treat boot-migration as a no-op. Putting it in createDb() rather than a hook also means every caller is migrated, including scripts and tests, not just HTTP traffic.
The Cloudflare D1 workflow (the gotcha that isn't obvious)
D1 is Cloudflare's managed SQLite, and the SvelteKit + D1 combination needs one extra step: on D1 you manage migrations through Wrangler, not through drizzle-kit migrate.
# Generate the migration SQL as usual
npx drizzle-kit generate
# Apply it (--local for your local D1, --remote for production)
npx wrangler d1 migrations apply my-database --local
npx wrangler d1 migrations apply my-database --remote
Wrangler keeps a migration history table inside D1, so files run once, in order, and the --remote flag targets the real production database. I lost a day to this the first time — the SQL generation is identical, but who applies it is different. My SQLite/D1 kit scripts drizzle-kit generate (the generation side); applying to a remote D1 is a Wrangler operation rather than a drizzle-kit migrate call.
One correction to what you'll read in older posts: it's not true that drizzle-kit push can't work with D1 at all. If you configure the d1-http driver (account ID, database ID, and an API token with D1 edit permissions), push will sync your schema to a remote D1. It works — and it still produces no migration files, which is exactly why you treat it like any other push: fine for sketching, not for production.
One contrast worth stealing
The Supabase kit in the same family has no Drizzle at all. Its whole migration story is one line:
supabase db diff --schema public | supabase db push
It works, and it's less machinery — but it diffs against the linked project and pushes the result, so you still have no committed, reviewable .sql history in the repo. Once that database has real data, "what changed and when" starts living in the platform's history table instead of in your git log. That difference is the whole argument for versioned migrations, and it's easier to see when two kits in the same codebase take different approaches.
The decision rule
| Situation | Approach |
|---|---|
| Local development |
push is fine — fast iteration |
| CI/CD pipeline |
generate → review SQL → migrate / wrangler d1 migrations apply
|
| Production deployment | Versioned migrations, reviewed and applied explicitly |
| Multiple replicas on one database | Explicit pre-deploy step as the only writer; disable boot-time auto-migrate |
| Cloudflare D1 |
generate + wrangler d1 migrations apply --remote
|
Versioned, reviewable, hand-editable SQL migrations are the boring choice — and boring is what production wants.
This exact workflow ships in the two Drizzle-based VerdantStack SvelteKit starter kits — Multi-tenant SvelteKit Starter (SQLite/D1, v0.2.8, 298 tests) and SvelteKit + Postgres Starter (vendor-neutral Postgres, v0.1.6, 262 tests) — each at ≥95% line, branch, function, and statement coverage. The generated migrations live in the drizzle/ folder of each repo if you want a real example to crib from: github.com/verdantstack/sveltekit-postgres-starter. The third kit, SvelteKit + Supabase Starter (v0.2.6, 314 tests), is the Supabase-native one discussed above.
Live demos: postgres-starter.verdantstack-site.pages.dev (Postgres — applies its checked-in migrations at boot via createDb()) and multi-tenant-starter.verdantstack-site.pages.dev (D1).
Top comments (0)