Schema and migrations
Generate, review, and apply migrations
Turn a TypeScript schema change into reviewed SQL, apply it once, and understand when direct schema push is the wrong workflow.
A migration is a database change with a history. Each one is a SQL file, committed next to the code, applied exactly once. Treat the generated SQL as a draft. Read it before it touches data you care about.
Generate
After changing the schema, ask Drizzle Kit to write the SQL:
npx drizzle-kit generate --name=initial_schema
[✓] Your SQL migration ➜ drizzle/20260907210145_initial_schema/migration.sql 🚀
Kit compares the schema with a snapshot of the last run. On the first run there is no snapshot, so it writes the whole thing. The --name flag gives the folder a readable suffix. Open the file:
CREATE TABLE `notes` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`author_id` integer NOT NULL,
`title` text NOT NULL,
`body` text DEFAULT '' NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
CONSTRAINT `fk_notes_author_id_users_id_fk` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`email` text NOT NULL UNIQUE,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE INDEX `notes_author_created_idx` ON `notes` (`author_id`,`created_at`);
This is the moment to check your work. Is the foreign key there? The cascade? The index on the two columns you meant? Every decision from the schema lessons is visible in plain SQL. Commit this file together with the schema change.
Apply
Now run it against the database:
npx drizzle-kit migrate
Using 'node:sqlite' driver for database querying
[✓] migrations applied successfully!
Kit also creates a __drizzle_migrations table and records what it applied. Run migrate a second time and nothing happens: same one row in that table, no duplicate tables. That bookkeeping is what makes migrations safe to run on every deploy.
A second migration
Let’s add an optional archivedAt column to notes, because later we want to hide archived notes from lists. In the schema:
archivedAt: text('archived_at'),
No .notNull(), so existing rows get NULL. Generate and read:
npx drizzle-kit generate --name=add_archived_at
ALTER TABLE `notes` ADD `archived_at` text;
One line, harmless. Run migrate and the column exists.
Be careful when a new column is required. SQLite refuses to add a NOT NULL column that has no default, because it doesn’t know what to put in the existing rows. Plan that in steps: add it nullable, fill the values with an update, then tighten the constraint in a later migration. Never accept a generated statement you haven’t thought through against populated data.
What about push?
npx drizzle-kit push skips the SQL files. It diffs the schema against the live database and changes it directly. For a throwaway prototype that’s fast and fine.
For any database other people use, I always generate and migrate. The SQL file is evidence of what changed, a reviewer can read it, and the deploy script can apply it the same way in every environment. Push gives you none of that.
Lesson completed