Drizzle ORM Migrations: drizzle-kit generate, the Journal and migrate

Drizzle keeps the schema in TypeScript and the migrations in SQL. drizzle-kit generate diffs your schema against a snapshot of the last generation and writes a numbered .sql file plus an entry in meta/_journal.json; migrate reads that journal and applies whatever the database has not seen. Understanding the journal, the snapshots and the history table is what separates a smooth team workflow from mysterious "migration skipped" reports.

The schema is TypeScript#

Everything starts from table definitions written with Drizzle's schema builders. For PostgreSQL:

import { pgTable, integer, varchar, date, primaryKey } from 'drizzle-orm/pg-core';

export const owners = pgTable('owners', {
  id: integer('id').primaryKey(),
  firstName: varchar('first_name', { length: 50 }).notNull(),
  lastName: varchar('last_name', { length: 50 }).notNull(),
  telephone: varchar('telephone', { length: 20 }),
});

export const pets = pgTable('pets', {
  id: integer('id').primaryKey(),
  name: varchar('name', { length: 50 }).notNull(),
  birthDate: date('birth_date'),
  ownerId: integer('owner_id').notNull().references(() => owners.id),
});

A drizzle.config.ts tells the kit where the schema lives, which dialect to speak and where to write migrations:

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  dialect: 'postgresql',
  schema: './src/db/schema.ts',
  out: './drizzle',
  dbCredentials: { url: process.env.DATABASE_URL! },
});

What drizzle-kit generate produces#

Running npx drizzle-kit generate compares the current schema with the newest snapshot under meta/ and writes three things: a SQL file, a new snapshot, and a new entry in the journal.

drizzle/
  0000_create_owners_and_pets.sql
  0001_create_vets.sql
  meta/
    _journal.json
    0000_snapshot.json
    0001_snapshot.json

By default the kit invents a two-word name for the file; --name create_vets gives it one you will recognise in a year. The SQL file is ordinary DDL, with one Drizzle-specific marker between statements:

CREATE TABLE owners
(
    id         INT         NOT NULL,
    first_name VARCHAR(50) NOT NULL,
    last_name  VARCHAR(50) NOT NULL,
    telephone  VARCHAR(20),
    CONSTRAINT pk_owners PRIMARY KEY (id)
);
--> statement-breakpoint
CREATE TABLE pets
(
    id         INT         NOT NULL,
    name       VARCHAR(50) NOT NULL,
    birth_date DATE,
    owner_id   INT         NOT NULL,
    CONSTRAINT pk_pets PRIMARY KEY (id),
    CONSTRAINT fk_pets_owners FOREIGN KEY (owner_id) REFERENCES owners (id)
);

The --> statement-breakpoint comment exists because some drivers, notably MySQL's and SQLite's, refuse to execute several statements in one call. The migrator splits the file at the breakpoints and sends the pieces one by one. If you write SQL by hand into a Drizzle migration, put a breakpoint between statements or the file will fail on those engines while working on PostgreSQL.

The journal is the source of truth#

The migrator does not list the directory. It reads meta/_journal.json and applies the entries in order:

{
  "version": "7",
  "dialect": "postgresql",
  "entries": [
    { "idx": 0, "version": "7", "when": 1758400000000, "tag": "0000_create_owners_and_pets", "breakpoints": true },
    { "idx": 1, "version": "7", "when": 1758400001000, "tag": "0001_create_vets", "breakpoints": true }
  ]
}

Each entry names a file through tag and carries a when timestamp. A SQL file that is not in the journal is invisible; a journal entry without a file is an error. That is why a Drizzle migrations folder must always be committed and shipped together with its meta directory.

The snapshots serve generation, not application. 0001_snapshot.json is the schema as the kit understood it after migration one, and the next generate diffs your TypeScript against it. Deleting snapshots does not break migrate, but it breaks the next generate, which will then try to create every table again.

Applying migrations#

There are two ways to apply the folder, and they do the same thing. From the command line:

npx drizzle-kit migrate

Or from application code, typically at startup of a service or in a dedicated job, with the migrator for your driver:

import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';

const db = drizzle(process.env.DATABASE_URL!);
await migrate(db, { migrationsFolder: './drizzle' });

Both record what they applied in a history table called __drizzle_migrations. On PostgreSQL it lives in a schema named drizzle by default; the table and schema names are configurable. Each row stores a hash of the migration's SQL and the time it was created, taken from the journal's when field.

The migrator decides what is pending by comparing each journal entry's when with the timestamp of the last applied migration. A migration generated on a long-lived branch can carry an older timestamp than one already applied from another branch, and it is then skipped without an error. After a rebase, regenerate rather than merge journals by hand, and run drizzle-kit check, which verifies that snapshots and journal are consistent.

push versus migrate#

drizzle-kit push skips migrations entirely: it diffs the schema against a live database and applies the difference directly. It is quick and it is right for prototyping, for a local database you are happy to recreate, and for some serverless setups where a migration history has little value. It is wrong for anything shared, because there is no record of what changed, no file to review, and no way to reproduce the same sequence on another environment. generate plus migrate is the workflow for staging and production. Mixing the two on one database produces drift, since push changes tables the journal knows nothing about; the article on schema drift explains how to catch that before it hurts.

Custom SQL migrations#

Not everything is expressible in the schema builders, and some things should not be generated. Data backfills, partial indexes, triggers and renames all belong in hand-written SQL:

npx drizzle-kit generate --custom --name backfill_owner_phone

The command creates an empty SQL file and its journal entry; you fill in the statements, separated by breakpoints. For renames specifically, the generator has to guess whether a column was renamed or dropped and re-added, and it asks interactively during generate. Answer carefully: the wrong answer produces a DROP COLUMN that discards data, and once the file is committed nobody will re-ask the question.

A team workflow that holds up#

  1. Change the TypeScript schema in a feature branch.
  2. Run generate with a name, read the SQL, commit the SQL file, the snapshot and the journal together.
  3. Before merging, rebase onto main; if main gained migrations, delete your generated migration, snapshot and journal entry and generate again so that the timestamp and the snapshot chain are correct.
  4. Run drizzle-kit check in CI, and apply migrations in CI against a throw-away database to prove the folder runs from scratch.
  5. Deploy with migrate as a step before the new application version starts, never with push.

Step four is where most teams save the most time. A migrations folder that is applied to an empty database on every pull request cannot accumulate the ordering problems described above, and it gives you an artifact you can look at: the schema those files really produce.

Where SchemaDrawer fits#

That artifact is what SchemaDrawer returns. Upload the Drizzle output folder, the SQL files together with meta/_journal.json, with migrationType=DRIZZLE and the engine and version you run in production. The service applies the folder to a throw-away PostgreSQL, MySQL, MariaDB or SQLite database in Docker, reads back the tables, columns, primary keys and foreign keys, hides __drizzle_migrations, and returns an SVG diagram of what the journal actually builds. The Drizzle page shows the exact upload layout and a GitHub Action step that opens a pull request with the refreshed diagram.

Key takeaways#

  • Schema in TypeScript, migrations in SQL, order in meta/_journal.json: commit all three together.
  • Put --> statement-breakpoint between statements in hand-written migrations.
  • Pending migrations are chosen by timestamp; regenerate after a rebase and run drizzle-kit check.
  • Use push for prototypes only; use generate and migrate for anything shared.
  • Prove the folder runs from scratch in CI and keep a picture of the schema it produces.
Marek Hudyma
Builds SchemaDrawer, a service that draws database diagrams by running your migrations on a real engine. About
Keep reading

Related articles

Draw your schema in the next five minutes

One HTTP call with your migration files, one poll, one SVG. Free while we build it.