Drizzle Kit β A Lightweight Database Versioning Toolkit for the TypeScript Ecosystem
Iβve been doing full-stack development with Hono lately and picked up Drizzle for database management. The experience has been refreshing β here are my notes.
Drizzle ORMβs CLI tool, Drizzle Kit, generates migrations without ever connecting to a database. It diffs exclusively against local JSON snapshots. The same schema always produces the same SQL β you can commit it to Git, run it through Code Review, and never worry about drift between environments or DDL discrepancies.
This article covers the core workflow first, then dives into how its versioning internals actually work.
Schema Definition β Your Tables Are Just TypeScript
Drizzle doesnβt use a standalone schema language like .prisma. You define tables directly in TypeScript:
// src/db/schema.ts
import {
pgTable,
serial,
text,
varchar,
integer,
timestamp,
} from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: serial("id").primaryKey(),
email: varchar("email", { length: 255 }).notNull().unique(),
name: text("name").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
Setup is equally clean:
bun add drizzle-orm pg # core + database driver
bun add -D drizzle-kit tsx @types/pg # dev dependencies
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
dialect specifies your database (MySQL, SQLite, Turso, etc. are also supported), schema points to your table definitions, and out is the migration output directory.
Database connection:
// src/db/index.ts
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
export const db = drizzle(pool, { schema });
This article uses PostgreSQL for examples. The API is the same across other dialects.
Push or Generate?
Drizzle Kit offers two approaches to sync your schema with the database:
push: Apply Changes Instantly
bunx drizzle-kit push
What it does: reads schema β serializes to a snapshot β introspects the database β computes the diff β executes DDL directly. Great for rapid prototyping, solo projects, and blue-green deployments with serverless databases (Neon, Turso). Dead simple.
But it doesnβt produce SQL files. No files means no Code Review, and CI/CD loses precise control. For team collaboration or production, the next approach is what you want.
generate + migrate: Generate SQL First, Then Execute
A production-grade two-step workflow.
Step 1: Generate migration files
bunx drizzle-kit generate
After the first run, your drizzle/ directory looks like this:
drizzle/
βββ meta/
β βββ _journal.json
β βββ 0000_snapshot.json
βββ 0000_fast_iron_lad.sql
Now add a column to posts:
published: boolean("published").default(false),
Run generate again:
drizzle/
βββ meta/
β βββ _journal.json
β βββ 0000_snapshot.json
β βββ 0001_snapshot.json
βββ 0000_fast_iron_lad.sql
βββ 0001_eager_black_widow.sql # new
The generated SQL:
ALTER TABLE "posts" ADD COLUMN "published" boolean DEFAULT false;
A detail worth noting when renaming columns: If you rename name to fullName, Drizzle canβt automatically tell whether this is a rename or a drop + add. It prompts you interactively:
Is fullName column in users table created or renamed from another column?
β― ~ name βΊ fullName rename column
+ fullName create column
Step 2: Apply migrations
bunx drizzle-kit migrate
It connects to the database β queries the __drizzle_migrations table β finds unexecuted migration SQL β runs them in order. A migration never runs twice.
Managing Migrations in Practice
Now that you have SQL files, the real question is: how should production and development environments consume these migration files?
The community consensus: push freely in development, auto-migrate in production, and let tooling guarantee you never miss a migration file.
-
Auto-generate migration SQL
Donβt rely on remembering to run generate before deploying. Add a pre-commit hook that auto-runs it whenever schema.ts changes in the staging area:
# lefthook.yml
pre-commit:
commands:
drizzle-generate:
glob: "*.{ts,tsx}"
run: |
if git diff --cached --name-only | grep -q "schema\.ts"; then
bunx drizzle-kit generate
git add drizzle/
fi
For Husky, just port the core logic into .husky/pre-commit. This guarantees every commitβs .sql is always in sync with the latest schema.
-
Auto-run migrations before app startup
Losing dev data doesnβt matter β bunx drizzle-kit push applies changes instantly without generating files or breaking your flow.
But production must use migrate() β run it automatically before the app starts. Gate it on NODE_ENV:
// src/server.ts
import { migrate } from "drizzle-orm/node-postgres/migrator";
if (process.env.NODE_ENV === "production") {
await migrate(db, { migrationsFolder: "./drizzle" });
}
// Server starts only after migrations complete
When You Need to Transform Data in a Migration
Drizzle Kit generates pure DDL (create tables, alter columns, add indexes). It doesnβt touch data. When you genuinely need data migration β say, splitting name into first_name + last_name β just append your DML to the generated .sql file:
ALTER TABLE "users" ADD COLUMN "first_name" text;
ALTER TABLE "users" ADD COLUMN "last_name" text;
-- Manual: data migration
UPDATE "users" SET
"first_name" = split_part("name", ' ', 1),
"last_name" = split_part("name", ' ', 2);
Seed & Studio β Essential DX Upgrades
seed: Populate Test Data
drizzle-seed is the official seeding utility. Install and go:
bun add drizzle-seed
Compared to hand-written SQL scripts or third-party data generators, drizzle-seedβs biggest advantage is that it shares type definitions with your Drizzle schema. Change the schema, and your seed file fails at compile time β no more βupdated the table but forgot to update the seed scriptβ footguns.
studio: A Small, Polished Database Web UI
bunx drizzle-kit studio
Once running, open https://local.drizzle.studio in your browser: browse tables, inspect data, edit records, check foreign key relationships. Enough for day-to-day debugging β no separate DBMS needed.
The Full Workflow
Under the Hood: Why Is It Reliable?
generate never touches a database β how does it know what SQL to produce? Letβs dig into what we glossed over earlier: the meta/ directory, snapshot files, and the __drizzle_migrations table.
The meta/ Directory
Every generate run saves a complete schema history locally:
drizzle/
βββ meta/
β βββ _journal.json # migration manifest
β βββ 0000_snapshot.json # initial schema snapshot
β βββ 0001_snapshot.json # schema after the first change
βββ 0000_fast_iron_lad.sql
βββ 0001_eager_black_widow.sql
_journal.json is the table of contents:
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1709553600000,
"tag": "0000_fast_iron_lad",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1709640000000,
"tag": "0001_eager_black_widow",
"breakpoints": true
}
]
}
entries lists every migration chronologically; tag matches the filename prefix. But XXXX_snapshot.json is where the real action is β itβs the complete serialization of your schema at that point in time:
// XXXX_snapshot.json
{
"version": "7",
"dialect": "postgresql",
"id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {}
}
},
"enums": {},
"schemas": {}
}
Pay attention to two critical fields:
id: the unique identifier of this snapshotprevId: points to the previous snapshotβsid
id β prevId forms a singly linked list. Each snapshot knows exactly which state it evolved from.
The generate Flow, Visualized
To summarize, itβs a four-step process:
- Read the current TypeScript schema, serialize it as a new snapshot JSON
- Find the last entry in
_journal.json, load the corresponding previous snapshot - Diff the two snapshots, compute what changed (columns added, tables dropped, indexes modifiedβ¦)
- Translate the diff into target-dialect DDL SQL and write the
.sqlfile
The database is never touched at any point.
What this means: migrations can be generated offline (CI/CD doesnβt need a database connection); the same schema always yields the same SQL; the prevId chain guarantees snapshot integrity β lose one snapshot in the middle and the next generate will error out immediately.
__drizzle_migrations β The Execution Ledger in Production
generate ensures βstable, correct migration SQL.β migrate tracks βwhich SQL hasnβt been executed yetβ β so any version of a database can be smoothly brought up to date. The latter relies on a single table inside the database:
-- PostgreSQL: defaults to the drizzle schema; this is the DDL from source
TABLE drizzle.__drizzle_migrations (
id serial PRIMARY KEY,
hash text NOT NULL,
created_at bigint
);
| Column | Purpose |
|---|---|
hash | SHA256 of the migration file β guarantees the file hasnβt been tampered with |
created_at | Corresponds to the when field in _journal.json (millisecond timestamp) |
An interesting detail: there is no name column. Migration filenames (0000_fast_iron_lad) exist only in _journal.jsonβs tag field. Drizzle determines which migrations have been executed by created_at timestamps, not filenames.
This is exactly why a GitHub Discussion thread had folks complaining that Drizzle stores hashes instead of migration filenames, making debugging unnecessarily painful.
Hereβs the migrate execution logic: read _journal.json β query __drizzle_migrations for the largest created_at β iterate all migrations; any where folderMillis > the largest recorded created_at goes onto the pending list β execute them in _journal.json entry order β after each one completes, insert a (hash, created_at) record.
One more note on transaction behavior: under PostgreSQL, all pending migration files are wrapped in a single transaction. This causes trouble with operations like enum alterations β GitHub Issue #3249 discusses this in detail. MySQL / SQLite / SingleStore donβt support DDL transactions, so they rely on the --> statement-breakpoint separator to execute statements one by one, each implicitly auto-committed.
Architecture Overview
Summary
Local JSON snapshots track every schema change β generate diffs old and new snapshots to produce SQL β migrate queries __drizzle_migrations to execute unapplied files. Entirely offline, deterministic, and reviewable.