Drizzle ORM Migrations: A Practical drizzle-kit Guide
Learn the full Drizzle ORM migration workflow: push vs migrate, drizzle-kit setup, Turso/libSQL config, team conflicts, and production best practices.
On this page
If you've added Drizzle to your Next.js project and gotten confused by push vs migrate, or you're not sure how to handle migrations in a team without conflicts, this guide covers the actual workflow. Not just the happy path.
This is the workflow DevEncyclopedia used when migrating its own production database from MongoDB to Turso. The Turso-specific setup reflects what we actually deployed.
push vs migrate: The Most Important Distinction
Drizzle is split into two packages: drizzle-orm (the runtime) and drizzle-kit (the CLI). Your schema is TypeScript. drizzle-kit reads it and does one of two things:
| drizzle-kit push | drizzle-kit generate + migrate | |
|---|---|---|
| Creates SQL files | No | Yes (committed to Git) |
| Tracks history | No | Yes (the __drizzle_migrations table) |
| Safe for production | Never | Yes |
| Speed | Instant | Fast |
| Best for | Local dev iteration | All team and production changes |
# Development: fast, no files
npx drizzle-kit push
# Production: generate first, review SQL, then apply
npx drizzle-kit generate
npx drizzle-kit migrateHow Drizzle Tracks Applied Migrations
Most of the confusion around generate comes from a wrong mental model. drizzle-kit generate does not look at your database. It compares your TypeScript schema against a snapshot of the last generated state and writes the difference out as SQL.
That snapshot lives next to your migrations. Every generate run produces two artifacts: a numbered .sql file and a JSON snapshot describing the full shape of your schema at that moment. The next run diffs against the snapshot, which is why generate works offline with no database credentials at all.
migrations/
0000_lively_iron_man.sql # the statements that get executed
0001_wide_stark_industries.sql
meta/
_journal.json # ordered index of every migration
0000_snapshot.json # schema shape after 0000
0001_snapshot.json # schema shape after 0001Everything in that folder belongs in Git, including meta/. Developers who add migrations/meta to .gitignore because it looks like build output end up with duplicate migrations the first time a teammate runs generate: with no snapshot to diff against, drizzle-kit assumes the whole schema is new.
| Location | What it holds | Who writes it |
|---|---|---|
| migrations/*.sql | The statements applied to the database | drizzle-kit generate |
| migrations/meta/*_snapshot.json | Serialized schema after each migration, used as the diff base | drizzle-kit generate |
| migrations/meta/_journal.json | Ordered index with a timestamp per migration | drizzle-kit generate |
| __drizzle_migrations table | Hash and timestamp of every migration already applied | drizzle-kit migrate |
On the database side, the first migrate run creates a bookkeeping table called __drizzle_migrations. On PostgreSQL it is created inside a separate drizzle schema; on SQLite and Turso it is a plain table in the main database.
Each row records the hash of a migration file and the journal timestamp it was created with. When you run migrate, Drizzle reads the newest timestamp in that table and applies every journal entry that is newer than it. Anything older is treated as already done.
Setting Up drizzle-kit
- 1
Install and configure drizzle-kit
Create
drizzle.config.tsin your project root. This example uses PostgreSQL. Turso config is in Step 3:typescript — drizzle.config.tsimport { defineConfig } from 'drizzle-kit'; export default defineConfig({ schema: './src/db/schema.ts', out: './migrations', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL!, }, }); - 2
Define your schema and generate the first migration
typescript — src/db/schema.tsimport { pgTable, serial, text, integer, timestamp } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: serial('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), createdAt: timestamp('created_at').defaultNow(), }); export const posts = pgTable('posts', { id: serial('id').primaryKey(), title: text('title').notNull(), authorId: integer('author_id').references(() => users.id), publishedAt: timestamp('published_at'), });bashnpm run db:generate # Creates ./migrations/0001_initial.sql - 3
Turso/libSQL-specific setup
If you're using Turso, the dialect, credentials, and column types all change from PostgreSQL:
typescript — drizzle.config.ts (Turso)import { config } from 'dotenv'; import { defineConfig } from 'drizzle-kit'; config({ path: '.env.local' }); // drizzle-kit does not auto-load .env.local export default defineConfig({ schema: './src/db/schema.ts', out: './migrations', dialect: 'turso', dbCredentials: { url: process.env.TURSO_DATABASE_URL!, authToken: process.env.TURSO_AUTH_TOKEN!, }, });- Install the Turso driver:
npm install @libsql/client - Use
sqliteTablefromdrizzle-orm/sqlite-coreinstead ofpgTable - Use
integer('id').primaryKey({ autoIncrement: true })instead ofserialfor auto-increment IDs
- Install the Turso driver:
- 4
Run migrations in production
Run migrations as part of your deployment process, not on application startup:
bash — Vercel: add to Build Commandnpm run db:migrate && next buildbash — Cloudflare: run before wrangler deploynpm run db:migrate npx wrangler deploy
Running Migrations From Code
The CLI is the fastest path, but it needs drizzle-kit, your config file, and your schema source present at the moment you run it. That is fine in CI and awkward inside a slim production container where devDependencies have been pruned.
The alternative is a small script built on the migrator that ships inside drizzle-orm itself. It needs only the generated SQL files and a database connection, and it gives you a real exit code to branch on.
import { config } from 'dotenv';
import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { migrate } from 'drizzle-orm/libsql/migrator';
config({ path: '.env.local' });
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN,
});
const db = drizzle(client);
try {
await migrate(db, { migrationsFolder: './migrations' });
console.log('Migrations applied');
} catch (err) {
console.error('Migration failed:', err);
process.exit(1);
} finally {
client.close();
}Run it with npx tsx scripts/migrate.ts. Each driver exports its own migrator, so only the import path changes: drizzle-orm/node-postgres/migrator, drizzle-orm/postgres-js/migrator, drizzle-orm/mysql2/migrator, drizzle-orm/better-sqlite3/migrator. The migrate call itself is identical in all of them.
The explicit process.exit(1) matters more than it looks. A migration script that logs an error and then exits with status 0 lets a broken deployment sail on to the next pipeline step, and the failure surfaces later as a runtime query error instead of a red build.
Note the dotenv call again: this script runs outside Next.js, so nothing loads .env.local for you. If the loading order of the various .env files is still fuzzy, our guide to Next.js environment variables covers which file wins in which context.
Troubleshooting
Edge cases you'll hit when working in a team:
- 1
Handling team migration conflicts
When two branches both add schema changes and merge, you can end up with two files both numbered
0002_something.sql. Fix by regenerating from the resolved schema:bash# 1. Resolve merge conflicts in schema.ts # 2. Delete the conflicting migration files # 3. Regenerate from the resolved schema npm run db:generate # Creates one clean migration covering all merged changes - 2
Reverting a migration
Drizzle does not generate automatic rollback files. To reverse a migration, write the reverse SQL by hand as a new forward migration:
- Additive changes (adding a table, adding a nullable column) are safe and rarely need reverting
- Destructive changes (dropping a column, changing a type) require a hand-written reverse migration and should always be tested on staging first
- Always export a database backup before any destructive production migration
Common drizzle-kit Errors and What They Mean
These are the failures that actually fill issue trackers, along with the reason each one happens. Most of them are the same misunderstanding in different clothes: drizzle-kit trusts the snapshot on disk, and the database trusts __drizzle_migrations, and the two can disagree.
| What you see | Cause | Fix |
|---|---|---|
| Please specify 'dialect' param in config | Config written for an older drizzle-kit that used `driver` | Replace `driver: 'pg'` with `dialect: 'postgresql'` |
| url: undefined, or a missing connection parameter | drizzle-kit never reads `.env.local` on its own | Load it explicitly with `dotenv` inside `drizzle.config.ts` |
| No schema changes, nothing to migrate | The `schema` path in the config does not match the file you edited | Point `schema` at the real path, or use a glob like `./src/db/schema/*.ts` |
| table "users" already exists | The database was built with `push`, so nothing was recorded as applied | Baseline the database, or reset it if it is a dev database |
| A migration that already ran tries to run again | An applied `.sql` file was edited after the fact and its hash changed | Restore the original file and add a new forward migration instead |
| Two migrations numbered 0002_ | Two branches generated migrations in parallel | Delete both files, regenerate once from the merged schema |
Switching from push to migrate on an existing database
This is the most common one-way door. You built the schema locally with push, the tables exist, and now you want a real migration history. The first migrate run tries to execute CREATE TABLE users and fails, because push never wrote a single row into __drizzle_migrations.
On a development database the answer is easy: drop everything and let the migration rebuild it from scratch. On a database that holds data you cannot lose, you baseline instead. Generate the initial migration so the SQL file and the snapshot both exist, then tell Drizzle that migration is already applied by inserting its bookkeeping row by hand.
CREATE SCHEMA IF NOT EXISTS drizzle;
CREATE TABLE IF NOT EXISTS drizzle."__drizzle_migrations" (
id SERIAL PRIMARY KEY,
hash text NOT NULL,
created_at bigint
);
INSERT INTO drizzle."__drizzle_migrations" (hash, created_at)
VALUES ('<sha256 of 0000_init.sql>', 1717000000000);The created_at value is the when field for that migration in meta/_journal.json. If computing the file hash by hand feels fragile, run the same migration once against a throwaway empty database, then copy the row Drizzle writes there into your real database. Same result, no guessing.
After baselining, verify by running migrate again. It should report nothing to apply. If it tries to recreate tables, the timestamp you inserted is older than the journal entry you meant to skip.
Rename prompts and accidental data loss
Drizzle cannot tell the difference between renaming a column and dropping one while adding another. Both produce an identical diff, so generate stops and asks you which one you meant.
? Is email_address column in users table created or renamed from another column?
> + email_address create column
~ email > email_address rename columnChoose create column by mistake and the generated SQL drops email and adds an empty email_address. Every value in that column is gone the moment the migration runs, and the only way back is a backup.
push asks the same question and then applies your answer immediately, with no file to review in between. That is the single strongest argument for keeping it away from any database whose contents you care about.
Migrations in CI/CD
Two jobs, two different responsibilities. On pull requests you verify that the committed migrations match the schema. On deploy you apply them, before the new code goes live.
The verification job is the one teams skip, and it is the one that prevents the classic outage: a schema change merged without its migration, so freshly deployed code queries a column that does not exist yet.
name: Database
on:
pull_request:
paths:
- "src/db/**"
- "migrations/**"
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
# Catches duplicate numbering and broken journal entries
- run: npx drizzle-kit check
# Fails if the schema changed but no migration was committed
- name: Ensure migrations are up to date
run: |
npx drizzle-kit generate
git diff --exit-code migrations/Neither step needs database credentials. generate diffs against the committed snapshot and check only reads the journal, so this job runs happily on pull requests from forks where secrets are not available.
When git diff --exit-code fails, someone changed the schema and forgot the migration. The job log contains the diff itself, which is usually enough for the author to fix it without asking anyone what went wrong.
jobs:
migrate:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Apply migrations
env:
TURSO_DATABASE_URL: ${{ secrets.TURSO_DATABASE_URL }}
TURSO_AUTH_TOKEN: ${{ secrets.TURSO_AUTH_TOKEN }}
run: npx drizzle-kit migrate
deploy:
needs: migrate
runs-on: ubuntu-latest
steps:
- run: npm run deployThe needs: migrate line is the entire point of splitting the jobs. If the migration fails, the deploy never starts and production keeps serving the previous build against the previous schema. Without it you get a half-applied change and an application that expects the finished one.
Scope the credentials to a GitHub environment so the production database token is not readable from every workflow in the repository, and so you can require a manual approval before it is used. Our post on GitHub Actions security walks through the wider hardening steps for workflows that hold secrets like these.
Drift is the other thing worth watching: staging and production quietly diverging because somebody applied a fix by hand at 2am. drizzle-kit introspect pointed at a live database writes out the schema it actually finds, which you can diff against your committed schema file to see exactly what moved.
Configuration drift usually arrives first. If staging and production point at databases you did not expect, compare the environment files before you go digging in the schema: our EnvDiff tool shows which keys differ, which are missing, and which have diverged in value.
Zero-Downtime Schema Changes
A migration and a deployment are two separate events, and in the gap between them the old application code is running against the new schema. Almost every unsafe schema change is really a problem with that gap.
One rule follows from it: a migration must be compatible with the code that is already deployed. When it cannot be, split it into steps that are.
| Change | What goes wrong | Safe sequence |
|---|---|---|
| Add a nullable column | Nothing | Ship it in a single migration |
| Add a NOT NULL column with no default | The migration fails on any table that already has rows | Add it nullable, backfill, then add the constraint |
| Drop a column | Running instances still select it and start erroring | Remove it from the code and deploy, drop it in the next release |
| Rename a column | Old and new code cannot both be correct at once | Add the new column, write to both, backfill, drop the old one |
| Change a column type | Table rewrite holds a lock for the duration | New column, batched backfill, swap reads, drop the old |
| Add an index to a large PostgreSQL table | Writes block while the index builds | Build it concurrently, outside the migration transaction |
The pattern behind that right-hand column has a name: expand and contract. You expand the schema so both the old and the new code work against it, ship the code, then contract by removing whatever is no longer used.
A column rename becomes three releases:
- Expand: add
email_addressas a nullable column, and deploy code that writes to bothemailandemail_addresswhile still reading fromemail - Migrate: backfill
email_addressfromemail, then deploy code that reads fromemail_address - Contract: stop writing to
email, deploy, then generate a migration that drops it
That feels heavy for a rename, and it is. It is also the only version with no window where live requests fail, and once the habit exists each step costs a few minutes.
The NOT NULL case is worth showing as SQL, because the generated migration will not do this for you. Drizzle emits a single ALTER TABLE ... ADD COLUMN ... NOT NULL, which fails immediately on a table that already has rows. Split it into two files, one release apart:
ALTER TABLE "users" ADD COLUMN "role" text;
UPDATE "users" SET "role" = 'member' WHERE "role" IS NULL;ALTER TABLE "users" ALTER COLUMN "role" SET NOT NULL;You write the first file yourself: generate an empty migration with drizzle-kit generate --custom and put the statements in it. The second one you can let drizzle-kit produce normally, by adding .notNull() to the column in your schema once the backfill has finished everywhere.
If the locking behaviour behind that table is unfamiliar territory, the fundamentals are worth a detour. Our SQL database interview questions covers transactions, isolation levels, and index behaviour in the depth this section assumes.
And when a schema change implies a long-running data change, that work does not belong in a migration at all. It belongs in a job that can be retried, resumed, and observed, which is the argument our comparison of Postgres-backed durable workflows and Temporal works through in detail.
drizzle-kit Commands Reference
| Command | What it does | When to use |
|---|---|---|
| drizzle-kit generate | Creates SQL migration files from schema diff | Every time you change schema.ts |
| drizzle-kit migrate | Applies pending migration files to the database | Before every deployment |
| drizzle-kit push | Syncs schema directly without files | Development only (never production) |
| drizzle-kit studio | Opens Drizzle Studio GUI in your browser | Browsing/editing data locally |
| drizzle-kit introspect | Generates schema.ts from an existing database | Migrating from Prisma or another ORM |
Frequently Asked Questions
What is the difference between drizzle-kit push and drizzle-kit migrate?
| push | generate + migrate | |
|---|---|---|
| SQL files created? | No | Yes |
| History tracked? | No | Yes |
| Safe for production? | Never | Yes |
Use push in development for speed. Use generate + migrate for every production change.
Why does drizzle-kit push fail with 'url: undefined' on Turso?
drizzle-kit does not automatically load .env.local. Add this to the top of drizzle.config.ts:
import { config } from 'dotenv';
config({ path: '.env.local' });Also install dotenv as a dev dependency: npm install -D dotenv.
How do I handle migration conflicts when multiple developers change the schema?
- Resolve the conflict in
schema.tsfirst: get both sets of changes into one clean schema - Delete the conflicting migration files (both
0002_branch_a.sqland0002_branch_b.sql) - Run
drizzle-kit generateto produce one clean migration covering all merged changes - Commit migration files to Git and review them in pull requests the same way you review code
When should I run drizzle-kit generate?
Every time you change schema.ts. Commit the generated migration file alongside the schema change. In CI you can add a check that fails the build when a schema change exists without a corresponding migration file, preventing deployments with unapplied changes.
I'm migrating from Prisma. How is Drizzle's migration workflow different?
| Prisma | Drizzle | |
|---|---|---|
| Generate + apply in one command? | Yes (migrate dev) | No, separate generate and migrate |
| Shadow database needed? | Yes | No |
| Migration seeding? | Yes | No |
| Schema language | Prisma Schema Language (.prisma) | TypeScript |
Drizzle gives you more visibility and control. You are responsible for running generate before each deployment: it doesn't happen automatically.
Can I run Drizzle migrations from a Cloudflare Worker or a serverless function?
You can import the migrator there, but you should not. Serverless and edge runtimes have short execution budgets and no guarantee that one instance handles the call, so a migration can be cut off partway through and leave the schema in a state nobody planned for.
Run migrations from the deployment pipeline instead, as a step that completes before the new code goes live. drizzle-kit migrate in a CI job, or a tsx scripts/migrate.ts step, both work and both fail loudly enough to stop the deploy.
What is the __drizzle_migrations table and is it safe to delete?
It is how Drizzle knows what has already run. Each row holds the hash of a migration file and the timestamp from meta/_journal.json, and migrate applies only the journal entries newer than the newest row it finds.
Deleting it does not undo anything. Your tables stay exactly as they are, but Drizzle now believes nothing has ever been applied, so the next migrate run tries to replay every migration from the beginning and fails on the first CREATE TABLE. If you have already deleted it, recreate the rows using the baselining approach described above.
Why does drizzle-kit generate report no changes when I clearly changed the schema?
Work through these in order. The first one is the cause the overwhelming majority of the time:
- The
schemapath indrizzle.config.tsdoes not point at the file you edited - The table is defined but never exported: drizzle-kit only sees exported symbols
- You split the schema across several files and pointed
schemaat just one of them. Use a glob such as./src/db/schema/*.ts, or an array of paths - The change is not one drizzle-kit can express, for example a TypeScript-level type annotation that does not alter the column definition
- A previous
generatealready captured the change, and the migration file is sitting uncommitted in your working tree
How do I write a migration by hand for a backfill or a data fix?
Use the --custom flag. It creates an empty, correctly numbered migration file and registers it in the journal, and you supply the SQL yourself:
npx drizzle-kit generate --custom --name=backfill_user_rolesThis is the right home for data changes that must run exactly once, in a known position relative to the schema changes around them. Keep them small, and make them idempotent where you can: a WHERE clause that makes a rerun harmless costs nothing to write and saves an afternoon when a deploy is retried.
For anything that touches millions of rows, write the migration so it only changes the schema and move the data work into a batched background job. A migration that runs for twenty minutes holds locks for twenty minutes.
The Drizzle migration workflow is straightforward once the push vs migrate distinction clicks: push to move fast in development, generate + migrate to make changes reviewable and auditable before touching production.
Commit your migration files, export a backup before destructive changes, and wire db:migrate into your deployment pipeline. That's the entire production discipline.
Everything else in this guide follows from two facts worth keeping in your head: generate diffs against the snapshot on disk, and migrate decides what to run from the __drizzle_migrations table. When the tool behaves in a way that seems impossible, one of those two is out of sync with what you assumed.
Add the pull request check that regenerates migrations and fails on a dirty diff, gate the deploy job behind the migration job, and split anything destructive into expand and contract steps. Those three habits cover the failures that are expensive to recover from.
Related Articles
How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)
Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.
5 async/await Mistakes That Slow Your JavaScript Code
Sequential awaits, await in forEach, missing Promise.all: these 5 async/await mistakes silently slow your JavaScript. Here's how to spot and fix each one.