Skip to content
Chapter 40Lesson 1

The Drizzle Kit migration loop

How Drizzle Kit turns a schema edit into a versioned SQL migration and applies it to your Postgres database.

Your db/schema.ts describes your organizations, invoices, and line items in precise TypeScript. But point db.select().from(invoices) at a fresh Neon branch and it errors: relation “invoices” does not exist. The schema is only a description; nothing has built a database to match it.

Making the tables exist is the first problem. The harder one is reaching every database the same way: your teammate’s laptop, CI, production, and every change you make after. Add a column next week and it has to reach all of them, with a record of exactly what ran and when. That is the migration problem, and Drizzle Kit solves it for this stack. This lesson runs the full loop: edit the schema, generate a migration, read its SQL, apply it, confirm the result, and commit the right files so the next person to pull your branch gets exactly what you got.

One distinction trips people up, so start with it. drizzle-orm is the runtime library your app imports to build and run queries. Drizzle Kit is a separate command-line tool that lives in your devDependencies and never runs in your deployed app. Its one job is to read db/schema.ts and turn it into SQL migration files.

This is the default here, rather than writing SQL by hand, because the tool lives in the same TypeScript codebase as your schema. There is no second language to learn and no hand-maintained pile of SQL files drifting out of sync with your types: you edit the schema, and the tool derives the rest.

One idea anchors the rest of the lesson. When you run generate, Drizzle Kit compares your current db/schema.ts against a saved snapshot of the last known schema, and the difference becomes a new SQL file. The diagram below walks through one round of editing the schema and shipping the change.

STEP 1 / 4 In sync The schema and the saved snapshot describe the same tables — nothing is pending.
db/schema.ts your intent
pgTable('invoices', {   id, total, status, })
= in sync
snapshot last known
meta/0000_snapshot.json matches the schema
DDL
Postgres no archived_at yet

The snapshot is the saved schema state on disk, the file generate diffs against. Here it matches db/schema.ts, so there is nothing to generate and the database is untouched.

STEP 2 / 4 You edit the schema A new column is added. The snapshot still describes the old shape — it is now stale.
db/schema.ts your intent
pgTable('invoices', {   id, total, status, + archivedAt: timestamp() })
diverged
snapshot last known
meta/0000_snapshot.json still the old shape
DDL
Postgres no archived_at yet

Adding an archivedAt column moves the schema ahead of the snapshot. Nothing has run yet; this is just an edited file.

STEP 3 / 4 generate Diffs the two, writes the difference as a numbered SQL file, and refreshes the snapshot.
db/schema.ts your intent
pgTable('invoices', {   id, total, status, + archivedAt: timestamp() })
= in sync
snapshot last known
meta/0001_snapshot.json refreshed to match
DDL
Postgres no archived_at yet
$ drizzle-kit generate
0001_add_invoices_archived_at.sql new — a file you can read
written to disk
snapshot → refreshed now matches the schema again

generate produces two things: a numbered 0001_….sql file you can read, and a refreshed snapshot that matches the schema again. Postgres is still untouched.

STEP 4 / 4 migrate Applies the pending SQL file to the database and records that it ran.
db/schema.ts your intent
pgTable('invoices', {   id, total, status, + archivedAt: timestamp() })
= in sync
snapshot last known
meta/0001_snapshot.json matches the schema
migrate
Postgres archived_at exists
__drizzle_migrations: 0001 ✓
migrate runs this file against the DB ↑
0001_add_invoices_archived_at.sql the pending file from generate

migrate runs the pending file against the database, so archived_at now exists, and records the run in a __drizzle_migrations table so it never runs twice.

The refreshed snapshot from step 3 is the part people forget, and the part that makes the next diff possible.

The SQL in these files is DDL , the statements that change a database’s structure.

Before any command runs, Drizzle Kit needs four things: which database dialect you’re targeting, where your schema lives, where to write migrations, and how to connect. You answer all four in one file at the repo root, drizzle.config.ts, which generate, migrate, push, and studio all read.

import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL_UNPOOLED! },
casing: 'snake_case',
verbose: true,
strict: true,
});

Drizzle Kit speaks several SQL dialects. This pins it to Postgres, so it emits Postgres SQL: timestamptz, serial, and the rest.

import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL_UNPOOLED! },
casing: 'snake_case',
verbose: true,
strict: true,
});

The input path. Drizzle Kit reads only what this file exports, so a table you defined but forgot to export doesn’t exist as far as migrations are concerned. If your schema spans several files, point this at a glob instead: './src/db/*.ts'.

import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL_UNPOOLED! },
casing: 'snake_case',
verbose: true,
strict: true,
});

Where the SQL files and their snapshots get written. This folder is checked into git; the next section opens it up.

import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL_UNPOOLED! },
casing: 'snake_case',
verbose: true,
strict: true,
});

The connection migrate, push, and studio use, and it’s the unpooled URL on purpose. Migration DDL holds long transactions that don’t survive transaction-mode pooling, so the pooled URL would truncate or fail them. The ! is a non-null assertion: TypeScript can’t prove the env var is set, so you promise it.

import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL_UNPOOLED! },
casing: 'snake_case',
verbose: true,
strict: true,
});

Mirrors the same setting on your db client, telling Drizzle Kit to emit created_at for a TypeScript field named createdAt. The client and this config must agree: if they disagree, the SQL Kit generates won’t match the column names your app queries at runtime.

import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: { url: process.env.DATABASE_URL_UNPOOLED! },
casing: 'snake_case',
verbose: true,
strict: true,
});

verbose prints the SQL Kit is about to run, so nothing happens behind your back. strict makes push confirm before it touches the database. Both are safety defaults; leave them on.

1 / 1

The drizzle folder is source code, not build output or a cache, so commit it like the rest of src/. Here it is after a couple of migrations.

  • Directorydrizzle/
    • 0000_lively_punisher.sql
    • 0001_add_invoices_archived_at.sql
    • Directorymeta/
      • _journal.json the ordered ledger of migrations
      • 0000_snapshot.json
      • 0001_snapshot.json

The numbered .sql files are your migrations. The 0000_, 0001_ prefix is a sequential counter, Drizzle Kit’s default, so the folder reads top to bottom as history. (Some teams use a timestamp instead; more on why at the end of the lesson.) The word after the number is either a random label or a name you supplied.

meta/ holds two kinds of file. _journal.json is the ordered ledger: which migrations exist and the order they apply in. Drizzle Kit reads it to know what’s next; the migration runner reads it to know what’s already done. The *_snapshot.json files capture the full schema at each migration, and they’re what generate diffs against: it never inspects your live database, it compares your schema file to the latest snapshot on disk.

So three artifacts always move together, in one commit:

  1. the db/schema.ts edit,
  2. the new .sql file,
  3. the new meta/ snapshot.

Split them across commits, or commit the schema and SQL but leave the snapshot behind, and the history desyncs.

generate: turning a schema change into a SQL file

Section titled “generate: turning a schema change into a SQL file”

drizzle-kit generate reads db/schema.ts, diffs it against the latest snapshot, and writes a new numbered .sql file plus a fresh snapshot. You never hand-write the file, but once it’s on disk it’s an ordinary text file you can open and edit before committing, which the next lesson covers.

We’ll add an archivedAt column to the invoices table: a nullable timestamp marking an invoice as archived. This single change is the worked example for the rest of the lesson, so we can watch one edit travel the whole loop.

The schema edit is one line:

src/db/schema.ts
export const invoices = pgTable('invoices', {
// ...existing columns
archivedAt: timestamp('archived_at', { withTimezone: true }),
});

Then run generate, passing a name:

Terminal window
pnpm db:generate --name add_invoices_archived_at

Drizzle Kit sees a column the snapshot doesn’t have and writes this:

drizzle/0001_add_invoices_archived_at.sql
ALTER TABLE "invoices" ADD COLUMN "archived_at" timestamptz;

The mapping back to your schema is direct. The field archivedAt became the column archived_at, which is casing: 'snake_case' at work, and timestamp(..., { withTimezone: true }) became timestamptz, the timezone-aware Postgres type. You wrote no SQL; the tool derived all of it.

Without --name, Drizzle Kit names the file with a random adjective-noun pair like 0001_brave_phoenix.sql. With it, you get 0001_add_invoices_archived_at.sql. The difference shows up months later: named migrations turn git log -- drizzle/ into an ordered changelog of every structural change to the database, while brave_phoenix and lively_punisher tell you nothing.

Random names are fine for a throwaway experiment; name production migrations every time, as a verb-and-noun phrase describing the change: add_invoices_archived_at, create_line_items_table, drop_legacy_status.

migrate: applying pending files to the database

Section titled “migrate: applying pending files to the database”

Generating a file changes nothing in your database: the column still isn’t there. To apply it, run drizzle-kit migrate. It connects to DATABASE_URL_UNPOOLED, walks the journal in order, applies every file that hasn’t run yet, and records each one in a __drizzle_migrations table.

Two properties make this command safe to lean on.

It’s idempotent. With nothing pending, it checks __drizzle_migrations, sees every file recorded, and exits a no-op, so it’s safe to run on every deploy.

It’s ordered. Files apply strictly in journal order: 0001 before 0002 before 0003, on every machine.

That ordering lets the same migrations run in three places with identical results. You run pnpm db:migrate on your laptop right after generating. CI runs it against a staging branch to confirm the migration applies cleanly before anyone merges. The production deploy runs it against the production database before the new app version goes live, so the tables exist by the time the new code queries them (the deploy pipeline orchestrates this; that’s later material).

Run pnpm db:migrate now and the worked example is complete: archived_at exists on the real invoices table, with a row in __drizzle_migrations recording that 0001 ran.

The same operation is also available programmatically, as a function you call from your own code:

src/db/migrate.ts
import { migrate } from 'drizzle-orm/neon-serverless/migrator';
import { dbUnpooled } from './db';
await migrate(dbUnpooled, { migrationsFolder: './drizzle' });

One detail to get right: the import path is driver-specific. This course uses the Neon serverless driver, so the migrator comes from drizzle-orm/neon-serverless/migrator. The generic Drizzle docs usually show drizzle-orm/node-postgres/migrator, a different driver whose import won’t resolve here, so match the path to the driver your db client uses. Note the client too: dbUnpooled, not the pooled db, since the unpooled rule holds here as for the CLI.

Reach for this when something needs to apply migrations in code rather than by shelling out to the CLI, such as a custom CI script or an app-startup hook. It uses the same __drizzle_migrations table and journal as the CLI, so the two are interchangeable. The course’s default stays the CLI in the deploy pipeline.

A migration is code, so it goes through code review.

A schema-changing pull request carries the three artifacts you know: the db/schema.ts edit, the generated .sql, and the meta/ snapshot. The habit that matters: read the SQL, not the schema diff. The diff shows what someone intended; the SQL is what runs against production. They usually agree, but when they don’t, the SQL is the only place the gap shows.

That gap is dangerous because an innocent-looking edit can emit a destructive DROP. Rename a column carelessly and the generated SQL may drop the old column and add a new empty one, losing every value in it. The diff reads like a rename; the SQL says DROP COLUMN. Reading the SQL is how a team catches this before it ships.

Merged migrations are also immutable: a merged file is a permanent record of what ran against real databases, so you never edit it and never reorder it. If one turns out wrong, you fix it forward with a new migration that corrects it.

You’ve seen the loop in pieces; drag these into the order you’d follow to ship a schema change.

Order the steps of shipping a schema change with Drizzle Kit. Drag the items into the correct order, then press Check.

Edit db/schema.ts to add the column
Run drizzle-kit generate --name ... to produce the migration file
Review the emitted .sql to see exactly what will run
Run drizzle-kit migrate against the unpooled URL to apply it
Commit the schema, the .sql, and the meta/ snapshot together

You’ll often want to look at the database directly: to confirm a migration applied, eyeball a few rows, or sanity-check a query. Run drizzle-kit studio and it serves a local web app at https://local.drizzle.studio, pointed at whatever database is in dbCredentials.url. You can browse, filter, sort, run ad-hoc queries, and create, edit, or delete rows inline, with no setup beyond the drizzle.config.ts you already wrote.

What sets it apart from a generic SQL client is that it’s schema-aware. It reads your relations file, so you can start at an invoice and click through to its line items, following foreign keys as links instead of writing a join. It knows your column types too, and renders and validates them accordingly.

To finish the worked example, open Studio, find the invoices table, and confirm the archived_at column is there with type timestamptz. You’ll use Studio this way throughout development, including to check a seed run later in this chapter.

Studio isn’t your only option. TablePlus, pgAdmin, DataGrip, and Neon’s web console all connect to the same Postgres, trading Studio’s wired-in schema awareness for heavier features like query-plan visualization and saved connections. This course uses Studio for its zero-config, schema-aware browsing, but the real advice is to pick one and learn it well.

One hard line: Studio is not a production tool. Your credentials sit in plaintext in drizzle.config.ts, and Studio has no authentication of its own. It belongs on your local machine, pointed at your development database, and nowhere else.

Every command above ran as pnpm db:something rather than the full drizzle-kit invocation, because the short names are wrapper scripts:

package.json
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"db:push": "drizzle-kit push"
}
}

None of the scripts mention DATABASE_URL_UNPOOLED. The runner (your framework’s env loader, or a dotenv-style wrapper) loads the connection string into the environment, and drizzle.config.ts reads it from there; the script only names the command. The project starters ship exactly these scripts.

You’ll run generate and migrate daily; the rest are situational or covered next lesson, so recognize them rather than reach for them by default.

CommandWhat it doesWhen
generateEmit a migration file from the schema-vs-snapshot diffThe daily move; this lesson
migrateApply pending files to the database in journal orderThe daily move; this lesson
studioOpen the schema-aware GUI on your dev databaseThis lesson
pushSkip the file, apply the diff directly to the databaseLocal prototyping only; has a silent-data-loss failure mode covered next lesson
pullIntrospect an existing database into a schema fileWhen adopting Drizzle on a database that already exists
dropRemove a migration from the journalDev-only, for an unapplied migration; the production rule is next lesson
checkLint the migration history for collisionsWhen two branches each added a migration; see below

Sequential numbering has one team-scale failure mode. Picture two pull requests, both branched off 0005. Each adds a column and runs generate, and each produces a 0006_*.sql, because each branch’s snapshot only knew about 0005. Merge both and the journal has two 0006s.

drizzle-kit check flags the duplicate. Whichever branch merges second renumbers: delete its 0006, then re-run generate so the change lands as 0007 on top of the merged 0006. Some teams avoid the collision with timestamp prefixes (migrations: { prefix: 'timestamp' } in the config), which almost never collide. The course default stays sequential numbering plus check plus renumber-on-conflict.

The official Drizzle docs are the reference for everything here: the full flag set for each command, edge cases, and the config options this lesson didn’t touch.