Skip to content
Chapter 40Lesson 1

The Drizzle Kit daily loop

How Drizzle Kit, the command-line tool of the Drizzle stack, turns a schema edit into versioned SQL migration files and applies them to your Postgres database.

You have a db/schema.ts that describes your organizations, invoices, and line items in precise TypeScript. But point db.select().from(invoices) at a fresh Neon branch right now and it errors: relation “invoices” does not exist. Your schema is only a description, and nothing has built a database to match it. The tables you typed out so carefully don’t exist anywhere yet.

Getting the tables to exist is only the first problem, and a harder one waits behind it. How does your teammate’s laptop end up with the same tables? How does CI? How does production? And when you add a column next week, how does that change reach every one of those databases in the same way, with a record of exactly what ran and when? That is the migration problem, and the tool that solves it for this stack is Drizzle Kit. By the end of this lesson you’ll run the full loop: edit the schema, generate a migration, read the SQL it produced, apply it, and confirm the result in a GUI. You’ll also know which files to commit so the next person to pull your branch gets exactly what you got.

You already hold one principle from building the schema: db/schema.ts is your single source of truth. This lesson is the mechanism that makes that principle real. It shows how the schema in your TypeScript reaches a running Postgres, and keeps reaching it every time the schema changes.

There’s a distinction here that trips people up, so start with it. You already have drizzle-orm installed: that’s the runtime library your app imports to build and run queries. Drizzle Kit is a different package, a 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. The query layer and the migration tool are separate things that happen to share a brand name, so don’t let the shared name blur them together.

This tool is the default here, rather than writing SQL by hand, because it lives in the same TypeScript codebase as your schema. There’s no second language to learn, no separate migration DSL, and no hand-maintained pile of SQL files that slowly drift out of sync with your types. You edit one file, the schema, and the tool derives everything else.

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 state, and the difference between them becomes a new SQL file. In short, generate is a diff. Once that clicks, every folder, rule, and pitfall in this lesson follows from it.

The diagram below walks through that loop one step at a time. The slider moves you forward through a single 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

In sync. Your db/schema.ts and the saved snapshot describe the same tables, so there’s nothing to generate. The snapshot is the saved schema state on disk, the thing generate will diff against. 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

You edit the schema. You add an archivedAt column. The schema has moved, but the snapshot still describes the old shape, so the two have diverged and the snapshot is now stale. 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 diffs the two. It compares the schema against the snapshot and produces two things: a numbered 0001_….sql file you can read, and a refreshed snapshot that matches the schema again. The database is still untouched, since nothing has hit Postgres yet.

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 applies the file. It runs the pending 0001_….sql against the database, so the archived_at column now exists, and records that it ran in a __drizzle_migrations table so it won’t run again.

Notice that step 3 produces two things, not one: a .sql file you can read and a refreshed snapshot. The snapshot is the part most people forget exists, and it’s the part that makes the next diff possible. Hold onto that picture, because every section below points back to it.

One more term to pin down before moving on. The SQL these files contain is DDL , the statements that change a database’s structure. Migrations are made of DDL. That distinction matters, and it comes back when we talk about which database connection to use.

Before any command runs, Drizzle Kit needs to know 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 a single file at the repo root, drizzle.config.ts. This one config drives every command, since generate, migrate, push, and studio all read it. Get this file right once and every command works.

Step through it one key at a time. Most of these keys are doing real work, and a couple of them are the difference between a migration that ships and one that quietly corrupts your data.

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 can speak several SQL dialects. This pins it to Postgres, so the SQL it emits is 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. This is the same export gotcha from when you built the schema. If your schema is split across 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, and we’ll open it up in the next section.

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 that migrate, push, and studio use. This is the unpooled URL on purpose. Migration DDL holds long transactions that don’t survive transaction-mode connection pooling, so the pooled URL would truncate or fail them. That’s the reasoning you saw when you set up the two URLs. The ! is a non-null assertion: TypeScript can’t prove the env var is set, so you promise it. (Validating env at build time is its own topic later in this unit.)

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. It tells Drizzle Kit to emit created_at for a TypeScript field named createdAt. The client and this config must agree: if one uses snake_case and the other doesn’t, 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 statements Kit is about to run, so nothing happens behind your back. strict makes push ask for confirmation before it touches the database. Both are safety defaults, so turn them on and leave them on.

1 / 1

The out folder is source code. It isn’t build output and it isn’t a cache, so treat it the way you treat the rest of src/ and commit it. Here’s what it looks like 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, and it means the folder reads top to bottom as history. (Some teams switch the prefix to a timestamp; more on why at the end of the lesson.) The word after the number is either a random label or, better, a name you supplied.

Inside meta/ there are two kinds of file. _journal.json is the ordered ledger: the list, in order, of which migrations exist and the sequence they apply in. Drizzle Kit reads it to know what’s next, and the migration runner reads it to know what’s already done. The *_snapshot.json files are the saved schema states from the diagram, one per migration, each capturing what the whole schema looked like at that point. These snapshots are what generate diffs against: when you run generate, it doesn’t inspect your live database, it compares your schema file against the latest snapshot on disk.

That leads to the rule that matters most in this section. Three artifacts always move together, in the same commit:

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

One change, one commit, three files: they’re a unit. Splitting them across commits, or worse, committing the schema and SQL but leaving the snapshot behind, desyncs the history.

generate: turning a schema change into a SQL file

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

This is the move you’ll make most days. drizzle-kit generate reads db/schema.ts, diffs it against the latest snapshot, and writes a new numbered .sql file plus a fresh snapshot. Because the file is generated, you don’t hand-write it from a blank page. But once it’s on disk it’s an ordinary text file, and you can open it and edit it before you commit. (When you’d want to, and the judgment that takes, is the next lesson. For now, hold one fact: these files are generated, so you can edit them.)

Let’s run it for real. We’ll add an archivedAt column to the invoices table, a nullable timestamp we’ll eventually use to mark an invoice as archived. This one column is our worked example for the rest of the lesson, so we can watch a single change 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 looks at your schema, 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;

Read that against what you wrote and you can see the schema-to-SQL mapping directly. Your TypeScript field archivedAt became the column archived_at, which is the casing: 'snake_case' setting doing its job. And timestamp(..., { withTimezone: true }) became timestamptz, the timezone-aware Postgres type your schema convention asks for. You didn’t write a line of SQL, since the tool derived all of it from the schema.

That --name flag isn’t decoration. Without it, 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: a folder full of named migrations turns git log -- drizzle/ into a readable changelog of every structural change the database has ever undergone, in order. A folder full of brave_phoenix and lively_punisher tells you nothing.

Random names are fine for a throwaway experiment, but production migrations get named every time. Name them as a verb-and-noun phrase describing what changed, such as add_invoices_archived_at, create_line_items_table, or drop_legacy_status. The sequential numbering plus the names give you history you can actually read.

migrate: applying pending files to the database

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

Generating a file changes nothing in your database; re-read step 3 of the diagram if that surprised you. The file exists on disk, but the column still isn’t there. To apply it you 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 it manages for you.

Two properties make this command safe to lean on.

The first is that it’s idempotent. Run migrate again with nothing new pending and it does nothing: it checks __drizzle_migrations, sees every file is already recorded, and exits cleanly. When there’s nothing to do it’s a no-op, which is why it’s safe to run on every single deploy.

The second is that it’s ordered. Files apply strictly in journal order, never out of sequence. 0001 runs before 0002 runs before 0003, always, on every machine. The order you generated them in is the order they apply, everywhere.

That ordering is what 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 check the migration applies cleanly before anyone merges. And the production deploy pipeline 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. Same files, same order, three environments. (How the deploy pipeline orchestrates that is later material; for now, just note that the seam exists.)

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

There’s a second way to apply migrations, worth recognizing even though it isn’t the default. The same operation is available programmatically, as a function you call from your own code instead of through the CLI:

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, which is a different driver, so if you copy that one by reflex the import won’t resolve. Match the path to the driver your db client actually uses. Note which client goes in, too: it’s dbUnpooled, not the pooled db, since the unpooled-connection rule holds here exactly as it does 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 the same journal as the CLI, so the two are interchangeable; pick whichever fits where you’re calling from. The course’s default stays the CLI in the deploy pipeline, and this is the escape hatch for when you need the same thing in code.

The thread that runs through this whole chapter is simple: a migration is code, and it gets reviewed like code.

A pull request that changes the schema carries those three artifacts you already know: the db/schema.ts edit, the generated .sql, and the meta/ snapshot. The part that takes a moment to internalize is that the reviewer reads the SQL, not the schema diff. The schema diff tells you what someone intended. The .sql file is what will actually run against production. Those usually agree, but when they don’t, the SQL is the only place the disagreement is visible.

That gap matters because a one-line, innocent-looking schema change can emit a destructive DROP. Rename a column carelessly and the generated SQL might drop the old column and add a new empty one, losing every value in it. The schema diff looks like a rename, but the SQL says DROP COLUMN. Reading the SQL is how a team catches that before it ships, and it’s exactly the safety net that the push command, which skips the file entirely, doesn’t have. (That failure mode gets the full treatment next lesson; for now, know that reading the SQL is what catches it.)

This is also why migrations are immutable once merged. A migration file that has been merged is a permanent record of what ran against real databases. You never edit it and you never reorder it. If a merged migration turns out to be wrong, you don’t go back and fix that file; you write a new migration that corrects it, moving forward. (That forward-fix discipline is next lesson’s territory; for now, hold the rule that merged means frozen.)

You’ve now seen the whole loop in pieces. Put it back together in order: drag these into the sequence you’d actually 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

So far everything has been the command line. But you’ll often want to look at your database, to confirm a migration actually applied, eyeball a few rows, or sanity-check a query. Drizzle ships a GUI for exactly that. Run drizzle-kit studio and it spins up a local web app at https://local.drizzle.studio that talks to whatever database is in dbCredentials.url. It lists your tables, browses and filters and sorts rows, runs ad-hoc queries, and lets you create, edit, and delete rows inline, with zero setup beyond the drizzle.config.ts you already wrote.

What makes it worth using over a generic SQL client is that it’s schema-aware. It reads the relations you defined in your relations file, so you can start at an invoice and click through to its line items, following the foreign keys as links instead of writing a join. It knows your column types too, so it renders and validates them sensibly. It understands the shape of your data because it’s reading the same schema your app reads.

Close out the worked example here. Open Studio, find the invoices table, and confirm the archived_at column is there with type timestamptz. That’s the loop’s final beat: you edited the schema, generated, reviewed, migrated, and now you can see the result. Use Studio this way during development, to confirm a migration landed, to check a seed run later in this chapter, or to glance at a query result while you’re building.

There’s a hard line around it, though: Studio is not a production tool. The reason is concrete, not stylistic. Your database credentials sit in plaintext inside drizzle.config.ts, and Studio has no authentication model of its own. It’s bound to your local machine and your development database, and that’s the only place it belongs. Never point it at production.

Studio isn’t your only option, and it’s worth knowing where it sits. TablePlus, pgAdmin, DataGrip, and Neon’s own web console all connect to the same Postgres and all work fine. The trade is simple. Studio’s edge is that it’s already wired up, with no config beyond what you’ve got, and that it’s schema-aware, so traversing relations by clicking just works. The dedicated tools win on power features: deep query-plan visualization for the kind of EXPLAIN analysis you’d do when tuning a slow query, schema export, multi-tab SQL editors, and saved connections. The takeaway isn’t a feature matrix to memorize; it’s to pick one and learn it well. This course uses Studio because it’s already in the box and it understands your schema. If you reach for a heavier tool when you need to dig into query plans, that’s a fine instinct too.

You’ve seen every command run as pnpm db:something rather than the full drizzle-kit invocation. That’s deliberate: the team types one short, memorable command, and the env loading happens the same way every time. Here are the wrapper scripts that make that work:

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

Notice the scripts don’t mention DATABASE_URL_UNPOOLED anywhere. The connection string is loaded into the environment by the runner (your framework’s env loader, or a dotenv-style wrapper) and read inside drizzle.config.ts. The script only names the command. The project starters you’ll work from ship exactly these scripts, which is why every example above was a pnpm db:* line. This is the small piece of glue that makes the loop a habit instead of a chore.

Each command now has a place in your head, so here’s the whole surface in one table. Two of them, generate and migrate, are the ones you’ll run daily. The rest are situational or covered next lesson; you want to recognize them, not reach for them by default yet.

| Command | What it does | When | | --- | --- | --- | | generate | Emit a migration file from the schema-vs-snapshot diff | The daily move; this lesson | | migrate | Apply pending files to the database in journal order | The daily move; this lesson | | studio | Open the schema-aware GUI on your dev database | This lesson | | push | Skip the file, apply the diff directly to the database | Local prototyping only; has a silent-data-loss failure mode covered next lesson | | pull | Introspect an existing database into a schema file | When adopting Drizzle on a database that already exists | | drop | Remove a migration from the journal | Dev-only, for an unapplied migration; the production rule is next lesson | | check | Lint the migration history for collisions | When two branches each added a migration; see below |

The sequential numbering that makes history readable has one team-scale failure mode worth naming. Picture two pull requests, both branched off the same 0005 migration. Each one adds a column, each one runs generate, and each one produces a 0006_*.sql, because each branch’s snapshot only knew about 0005. Merge them both and your journal now has two 0006s, which is a conflict.

drizzle-kit check catches exactly this: it lints the journal for collisions and flags the duplicate. The fix is straightforward. Whichever branch merges second renumbers: delete its 0006, then re-run generate so the change lands as 0007, stacked cleanly on top of the now-merged 0006. The sequence is restored and the history stays linear.

Some teams sidestep the collision entirely by switching the migration prefix from sequential numbers to timestamps (migrations: { prefix: 'timestamp' } in the config), since two timestamps almost never collide. It’s a reasonable choice at scale. The course default stays sequential numbering plus check plus renumber-on-conflict, because the linear history reads more clearly for a small team and the collisions are rare and easy to resolve.

To make the daily-versus-situational split stick, sort each command into the right bucket.

Sort each Drizzle Kit command by how often you reach for it. Drag each item into the bucket it belongs to, then press Check.

Daily loop The everyday reflex
Recognize / deferred Situational or taught later
generate
migrate
studio
push
pull
drop
check
migrate() in app code

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.