Drizzle ORM Guide: Schema, Queries, Migrations and the Drizzle Kit
A practical guide to Drizzle ORM: schema definition, typesafe queries, relations, and running migrations with Drizzle Kit in a TypeScript project.
Why Drizzle ORM is Different
Drizzle hit 1.0 in 2024 and it genuinely rethinks what a TypeScript ORM should be. Most ORMs make you learn a new query language or fight the type system. Drizzle doesn't. Your schema IS TypeScript, your queries return inferred types without you lifting a finger, and the generated SQL looks exactly like what you'd write yourself.
In practice, the thing that sold me on it was the zero-runtime-overhead promise. It's a thin query builder, not an entity-manager. No hidden lazy-loading, no surprises when you hit production. You always know what SQL is going out the wire.
That said, it's not magic. Drizzle requires you to think in terms of tables and columns, not classes and decorators. If you're coming from TypeORM or Prisma and you lean heavily on the active-record pattern, there's a mental shift. Worth it, but it's real.
Drizzle also ships drizzle-kit — a separate CLI for schema diffing and migrations. They work together but are independent installs. That separation is actually useful once you have a CI pipeline.
Installing and Setting Up Drizzle
Start with two packages. You need the core ORM and the driver adapter that matches your database. For Postgres with the postgres driver:
``bash
npm install drizzle-orm postgres
npm install -D drizzle-kit
`
For SQLite with better-sqlite3, swap in better-sqlite3. For MySQL, use mysql2`. The API stays the same — only the adapter changes.
Create a drizzle.config.ts in your project root. Drizzle Kit reads this at migration time:
``ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
`
out` is where your migration SQL files land. Keep it in version control — those files are your audit trail.
One more thing — the dialect field was added in drizzle-kit 0.21. If you're on an older version you'll see a driver field instead. Check npx drizzle-kit --version before you copy configs off Stack Overflow.
Wire up the database client in a shared module so you import it everywhere:
``ts
// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client);
`
That's it. No class instantiation, no DataSource.initialize()`, no ceremony.
Defining Your Schema
The schema is plain TypeScript. You call column helpers from drizzle-orm and export the table. Here's a realistic example with a users table and a posts table:
``ts
// src/db/schema.ts
import { pgTable, serial, text, varchar, timestamp, integer } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: text('name'),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
body: text('body'),
authorId: integer('author_id').notNull().references(() => users.id),
publishedAt: timestamp('published_at'),
});
`
Notice .references(() => users.id)` — that's how you declare foreign keys. Drizzle uses a callback to avoid circular import issues between schema files.
Column types map pretty much 1-to-1 to Postgres types. serial → SERIAL, varchar(255) → VARCHAR(255), timestamp → TIMESTAMP. No magic mapping layer to guess around.
You can add indexes directly in the table definition using index() from drizzle-orm/pg-core:
``ts
import { pgTable, text, index } from 'drizzle-orm/pg-core';
export const posts = pgTable('posts', {
// ...columns
}, (table) => ({
titleIdx: index('posts_title_idx').on(table.title),
}));
`
The second argument to pgTable` is a callback that receives the table and returns an object of extra constraints. Indexes, unique constraints, composite PKs — all land here.
Honestly, this schema style beats Prisma's .prisma file for one reason: it's just TypeScript. You can import constants, compute column names, create factory functions, whatever you need. No custom DSL to fight with.
Querying with Drizzle
Drizzle gives you two query APIs. The first is the fluent builder that looks like SQL. The second is the "Relational Query API" (RQA) which is higher-level. Both return fully typed results.
The fluent builder:
``ts
import { db } from '@/db';
import { users, posts } from '@/db/schema';
import { eq, and, desc, gt } from 'drizzle-orm';
// SELECT with WHERE
const activeUsers = await db
.select()
.from(users)
.where(eq(users.email, 'jane@example.com'));
// JOIN
const userPosts = await db
.select({
postTitle: posts.title,
authorName: users.name,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(gt(posts.publishedAt, new Date('2026-01-01')))
.orderBy(desc(posts.publishedAt))
.limit(20);
`
The operators eq, and, gt, desc come from drizzle-orm` — they're just functions that return SQL fragments. No string interpolation, no injection vectors.
Inserts, updates, and deletes follow the same pattern:
``ts
// INSERT with RETURNING
const [newUser] = await db
.insert(users)
.values({ email: 'dev@example.com', name: 'Dev' })
.returning();
// UPDATE
await db
.update(posts)
.set({ title: 'Updated Title' })
.where(eq(posts.id, 42));
// DELETE
await db.delete(users).where(eq(users.id, newUser.id));
`
The returning() call maps directly to Postgres RETURNING *. You get back the inserted rows typed as typeof users.$inferSelect[]`. No extra roundtrip needed.
Quick aside: the Relational Query API needs you to pass the schema when creating the db instance: drizzle(client, { schema }). Then you can do db.query.posts.findMany({ with: { author: true } }) for nested eager loading. It's great for CRUD endpoints where you always need related data. That said, for complex analytical queries you'll still reach for the fluent builder.
Defining Relations
For the Relational Query API to work, you declare relations separately from your table columns. This is Drizzle's version of Prisma's @@relation:
``ts
import { relations } from 'drizzle-orm';
import { users, posts } from './schema';
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
`
Export these alongside your tables and import them all when you create the db instance: drizzle(client, { schema: { ...tables, ...relations } })`.
Once that's wired up, the RQA reads naturally:
``ts
const posts = await db.query.posts.findMany({
with: {
author: {
columns: { id: true, name: true },
},
},
where: (posts, { isNotNull }) => isNotNull(posts.publishedAt),
limit: 10,
});
// posts[0].author.name — fully typed
`
The where inside findMany` receives the table and all operators — no need to import them separately. Slightly different ergonomics from the fluent API but useful once you're used to it.
Worth noting: relations in Drizzle are purely runtime metadata. They don't affect the generated SQL schema or your migrations — those are driven only by your pgTable definitions. If you add or remove a relations() call, you won't get a new migration file. That's intentional, and it's actually sensible once you think about it. Foreign keys in the schema handle the actual DB-level constraint; relations are just ORM navigation hints.
Migrations with Drizzle Kit
This is where Drizzle Kit earns its keep. Instead of writing SQL by hand or running db push blindly, you generate versioned migration files:
``bash
# Generate a new migration based on schema changes
npx drizzle-kit generate
# Apply pending migrations
npx drizzle-kit migrate
`
generate diffs your current schema against the last known state and produces a .sql file in your out directory. migrate` runs those files against your database in order.
The generated migration from the schema above would look like:
``sql
-- drizzle/0000_initial.sql
CREATE TABLE IF NOT EXISTS "users" (
"id" serial PRIMARY KEY NOT NULL,
"email" varchar(255) NOT NULL,
"name" text,
"created_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
CREATE TABLE IF NOT EXISTS "posts" (
"id" serial PRIMARY KEY NOT NULL,
"title" text NOT NULL,
"body" text,
"author_id" integer NOT NULL,
"published_at" timestamp,
CONSTRAINT "posts_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "users"("id")
);
``
Readable, reviewable, deployable in any Postgres client. Not some proprietary binary format.
Drizzle Kit also has a drizzle-kit push command that skips the migration file entirely and just syncs the schema directly. Don't use this in production. It's useful in local dev and CI preview branches where the database is throwaway — you skip the migration ceremony and get a fresh schema in seconds. For production, always use versioned migration files.
If you need programmatic migration runs (say, at server startup in a Docker container), Drizzle exports a migrate function:
``ts
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { db } from './db';
await migrate(db, { migrationsFolder: './drizzle' });
`
This runs any pending .sql files in order. Safe to call on every startup since Drizzle tracks what's been applied in a __drizzle_migrations` table. If you're on Next.js and building UI on top of a database, this pairs nicely with checking out the Empire UI templates to scaffold that admin layer fast.
Transactions, Type Utilities, and Production Tips
Transactions work the way you'd expect:
``ts
await db.transaction(async (tx) => {
const [user] = await tx
.insert(users)
.values({ email: 'new@example.com' })
.returning();
await tx.insert(posts).values({
title: 'First post',
authorId: user.id,
});
});
`
If anything inside throws, the whole transaction rolls back. The tx object has the same API as db` so you don't need to thread a different client through every function.
Two type utilities you'll use constantly: typeof users.$inferSelect and typeof users.$inferInsert. The first is the shape of a row you get back from a SELECT. The second is what you pass to INSERT — optional fields are automatically marked as optional in the type. Export these as named types at the top of your schema file and use them across your app instead of repeating inline generics.
Look, Drizzle's performance at scale is genuinely good. It generates a single SQL query for most RQA calls, even nested ones. Compare that to ORMs that fire N+1 queries for a list with relations. With 64px padding between your UI cards and a database returning 500 rows of typed posts, the bottleneck won't be the ORM layer.
For a full-stack Next.js project, keep your schema in src/db/schema.ts, your relations in src/db/relations.ts, and the db client in src/db/index.ts. Pass the merged schema to your db instance. Run drizzle-kit generate whenever you touch schema files in a pre-commit hook. If you want a UI that looks as polished as your DB layer, browse components — a lot of the admin patterns (tables, filters, modals) are already built and drop straight into Next.js pages. Also check out the gradient generator for quick design system tokens when scaffolding that admin UI.
One final thing: Drizzle doesn't handle database creation or user management. It manages schema inside an existing database. Point DATABASE_URL at a provisioned Postgres instance, whether that's a local Docker container, a Supabase project, or a Neon branch, and you're off.
Comparing Drizzle to Prisma in Practice
If you've come from the Prisma guide on this blog, here's the honest diff. Prisma gives you a richer codegen experience and first-class support for things like upserts and nested writes in a single call. Drizzle gives you lower overhead, pure TypeScript schema, and SQL you can actually read.
The migration story is where Drizzle wins for me. Prisma's migration engine is opinionated and occasionally hard to reason about when you've applied manual SQL changes outside of Prisma. Drizzle's generate just diffs the schema TypeScript against the last snapshot — much more predictable.
Where Prisma still edges ahead: many-to-many convenience helpers, the Prisma Studio GUI, and the sheer breadth of documentation built up since 2019. Drizzle is newer and some edge cases (custom SQL types, multi-schema Postgres setups) require more legwork. If you're deciding right now for a greenfield app in 2026, Drizzle is the pick. For a large existing Prisma codebase, the migration cost is real — benchmark your specific query patterns before committing.
Also worth checking: prisma-vs-drizzle-orm is a dedicated comparison if you need to make the case to a team.
FAQ
Yes. Install better-sqlite3 and use drizzle-orm/better-sqlite3 as the adapter. The schema API is identical — just swap pg column helpers for their SQLite equivalents like integer, text, and blob.
You can. Use db.execute(sqlSELECT * FROM users WHERE id = ${id}) with the tagged sql template literal from drizzle-orm. It handles parameter binding safely so you're not doing string concatenation.
push syncs your schema directly to the database without creating a migration file. It's useful in local dev or throwaway preview environments. migrate applies versioned SQL files — use this in staging and production so you have an audit trail.
No. relations() is ORM-level metadata for the Relational Query API. The actual foreign key constraints come from .references() in your pgTable column definitions. Adding or removing a relations() call won't generate a new migration.