Drizzle ORM: Type-Safe SQL for TypeScript
If you've ever struggled with the tension between writing raw SQL for performance and using an ORM for developer ergonomics, Drizzle ORM might be the solution you've been looking for. It offers the best of both worlds: full type safety derived directly from your schema, SQL-like query syntax that feels familiar, and zero runtime overhead. Since its rise in the TypeScript ecosystem, Drizzle has become a go-to choice for developers building with Next.js, SvelteKit, Astro, and any TypeScript-first stack.
Drizzle ORM has surged to over 25,000 GitHub stars and is frequently ranked as the #1 ORM in developer satisfaction surveys among TypeScript developers. Its SQL-like API means there's almost no learning curve if you already know SQL — and the type inference means your IDE catches schema mismatches before runtime.
What is Drizzle ORM?
Drizzle ORM is a lightweight, TypeScript-native SQL ORM that takes a "schema-first" approach — you define your database tables in TypeScript, and Drizzle automatically infers all the correct types for your queries. Unlike heavier ORMs that abstract SQL behind a complex API, Drizzle embraces SQL and lets you write queries that map almost 1:1 to the SQL they generate.
Key characteristics that set Drizzle apart:
- Zero runtime overhead: Drizzle is just a query builder with a thin layer on top of your database driver — no magic, no hidden queries
- Full type inference: Column types, nullable fields, and relation shapes are all inferred from your schema definition
- SQL-like API: Methods like
select(),where(),orderBy(), andjoin()mirror SQL syntax - Multi-database support: PostgreSQL, MySQL, SQLite, and Turso (libSQL) all supported
- Drizzle Kit: A companion CLI for generating and running migrations
- Edge runtime compatible: Works in Cloudflare Workers, Vercel Edge, and other edge runtimes
How Drizzle ORM bridges TypeScript code to your database
Installation and Setup
Getting started with Drizzle requires installing the core package plus the appropriate driver for your database. The setup is intentionally minimal — no heavy configuration files, no code generation step before you can start querying.
Install Drizzle ORM and your database driver
Choose the driver that matches your database. Drizzle supports postgres.js, node-postgres (pg), mysql2, and better-sqlite3 among others.
# For PostgreSQL (using postgres.js driver)
npm install drizzle-orm postgres
npm install -D drizzle-kit
# For MySQL
npm install drizzle-orm mysql2
npm install -D drizzle-kit
# For SQLite (Node.js)
npm install drizzle-orm better-sqlite3
npm install -D drizzle-kit @types/better-sqlite3
Create your database connection
Initialize the Drizzle client with your database driver. This is typically done once in a db/index.ts file.
// 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);
// db/index.ts
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
export const db = drizzle(connection);
// db/index.ts
import { drizzle } from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
const sqlite = new Database('local.db');
export const db = drizzle(sqlite);
Configure Drizzle Kit
Create a drizzle.config.ts file at the project root to tell Drizzle Kit where your schema lives and how to connect to your database.
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});
Defining Your Database Schema
Schema definition is where Drizzle really shines. You write your tables in TypeScript using Drizzle's column builders, and the type system automatically knows the shape of every row you'll query. The schema file is the single source of truth for both migrations and TypeScript types.
PostgreSQL Schema Example
import {
pgTable,
serial,
varchar,
text,
integer,
boolean,
timestamp,
uuid,
} from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
bio: text('bio'),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
export const posts = pgTable('posts', {
id: uuid('id').primaryKey().defaultRandom(),
title: varchar('title', { length: 500 }).notNull(),
content: text('content').notNull(),
published: boolean('published').default(false).notNull(),
authorId: integer('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// TypeScript types are automatically inferred:
// type User = typeof users.$inferSelect
// type NewUser = typeof users.$inferInsert
Common Column Types by Database
| Concept | PostgreSQL | MySQL | SQLite |
|---|---|---|---|
| Auto-increment ID | serial('id') |
int('id').autoincrement() |
integer('id') |
| UUID primary key | uuid('id').defaultRandom() |
varchar('id', {length:36}) |
text('id') |
| Variable string | varchar('name', {length:255}) |
varchar('name', {length:255}) |
text('name') |
| JSON data | jsonb('metadata') |
json('metadata') |
text('metadata') |
| Timestamp | timestamp('created_at') |
datetime('created_at') |
integer('created_at') |
| Enum | pgEnum('role', ['admin','user']) |
mysqlEnum('role', ['admin','user']) |
text('role') |
Use typeof users.$inferSelect to get the type for a queried row, and typeof users.$inferInsert for an insertable row (where some optional fields with defaults may be omitted). This means you never need to manually write these types — they stay in sync with your schema automatically.
Querying Data with Drizzle
Drizzle provides two querying styles: a relational query API (similar to Prisma) and a SQL-like query builder. The builder is the core and most flexible option. Queries are fully lazy — nothing executes until you await them.
Select Queries
import { db } from './db';
import { users, posts } from './db/schema';
import { eq, and, like, gt, desc, count } from 'drizzle-orm';
// Select all users
const allUsers = await db.select().from(users);
// type: { id: number; name: string; email: string; ... }[]
// Select specific columns
const emails = await db
.select({ id: users.id, email: users.email })
.from(users);
// Filter with conditions
const activeUsers = await db
.select()
.from(users)
.where(and(eq(users.isActive, true), gt(users.id, 10)));
// Search with LIKE
const searchResults = await db
.select()
.from(users)
.where(like(users.name, '%John%'));
// Order and limit
const recentUsers = await db
.select()
.from(users)
.orderBy(desc(users.createdAt))
.limit(10)
.offset(0);
// Aggregate: count active users
const [{ total }] = await db
.select({ total: count() })
.from(users)
.where(eq(users.isActive, true));
Joins
// Inner join: posts with their authors
const postsWithAuthors = await db
.select({
postId: posts.id,
title: posts.title,
authorName: users.name,
authorEmail: users.email,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt));
// Left join: all users, even without posts
const usersWithPosts = await db
.select({
userName: users.name,
postTitle: posts.title, // null if user has no posts
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId));
Insert, Update, and Delete
import { db } from './db';
import { users, posts } from './db/schema';
import { eq } from 'drizzle-orm';
// INSERT — single row, returning inserted data
const [newUser] = await db
.insert(users)
.values({
name: 'Alice Johnson',
email: 'alice@example.com',
})
.returning();
// newUser.id is typed as number
// INSERT — multiple rows at once
await db.insert(posts).values([
{ title: 'First Post', content: 'Hello world', authorId: newUser.id },
{ title: 'Second Post', content: 'More content', authorId: newUser.id },
]);
// UPDATE — modify matching rows
const [updated] = await db
.update(users)
.set({ name: 'Alice Smith', updatedAt: new Date() })
.where(eq(users.id, newUser.id))
.returning();
// DELETE — remove rows
await db.delete(posts).where(eq(posts.authorId, newUser.id));
// Upsert (INSERT ON CONFLICT)
await db
.insert(users)
.values({ name: 'Bob', email: 'bob@example.com' })
.onConflictDoUpdate({
target: users.email,
set: { name: 'Bob Updated' },
});
Drizzle queries are lazy — they return a query builder object, not a Promise. They only execute when you await them (or call .then()). A common bug is forgetting the await keyword and receiving the builder object instead of the results. Always await your queries!
Database Migrations with Drizzle Kit
Drizzle Kit is the migration companion CLI that compares your current schema against your database and generates the SQL needed to bring them into sync. It works by introspecting your schema file and generating migration files in a folder you specify.
Generate a migration
Run this whenever you change your schema. Drizzle Kit will create a new migration SQL file in the drizzle/ folder with the changes needed.
# Generate migration (compares schema to DB, creates SQL file)
npx drizzle-kit generate
# Apply pending migrations to your database
npx drizzle-kit migrate
# Push schema directly without migrations (good for prototyping)
npx drizzle-kit push
# View current DB schema in the browser
npx drizzle-kit studio
Run migrations programmatically
For production deployments, you may want to run migrations from your application startup code rather than the CLI.
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
const db = drizzle(migrationClient);
await migrate(db, { migrationsFolder: './drizzle' });
await migrationClient.end();
Example Generated Migration SQL
-- drizzle/0001_create_users_and_posts.sql
CREATE TABLE IF NOT EXISTS "users" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(255) NOT NULL,
"email" varchar(255) NOT NULL,
"bio" text,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
CREATE TABLE IF NOT EXISTS "posts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"title" varchar(500) NOT NULL,
"content" text NOT NULL,
"published" boolean DEFAULT false NOT NULL,
"author_id" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_users_id_fk"
FOREIGN KEY ("author_id") REFERENCES "users"("id")
ON DELETE cascade ON UPDATE no action;
Working with Relations
Drizzle's relational query API lets you fetch related data in a single query without writing joins manually. You define relations separately from the schema, then use the db.query API to include related data.
Defining Relations
import { relations } from 'drizzle-orm';
import { users, posts } from './tables'; // your table definitions
// A user has many posts
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
// A post belongs to one user (author)
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
}));
Using the Relational Query API
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './db/schema';
// Pass the full schema (including relations) to the client
const db = drizzle(postgres(process.env.DATABASE_URL!), { schema });
// Fetch users with their posts (one query, no N+1)
const usersWithPosts = await db.query.users.findMany({
with: {
posts: {
where: (posts, { eq }) => eq(posts.published, true),
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit: 5,
},
},
});
// usersWithPosts[0].posts is fully typed as Post[]
// Fetch a single post with its author
const post = await db.query.posts.findFirst({
where: (posts, { eq }) => eq(posts.id, postId),
with: { author: true },
});
// post.author.name is typed as string
Many-to-Many Relations
import { pgTable, integer, primaryKey, varchar } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
export const tags = pgTable('tags', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
name: varchar('name', { length: 100 }).notNull().unique(),
});
// Junction table
export const postTags = pgTable(
'post_tags',
{
postId: integer('post_id').notNull().references(() => posts.id),
tagId: integer('tag_id').notNull().references(() => tags.id),
},
(t) => [primaryKey({ columns: [t.postId, t.tagId] })]
);
export const postsRelations = relations(posts, ({ many }) => ({
postTags: many(postTags),
}));
export const tagsRelations = relations(tags, ({ many }) => ({
postTags: many(postTags),
}));
export const postTagsRelations = relations(postTags, ({ one }) => ({
post: one(posts, { fields: [postTags.postId], references: [posts.id] }),
tag: one(tags, { fields: [postTags.tagId], references: [tags.id] }),
}));
Drizzle vs Other ORMs
Choosing an ORM involves trade-offs between type safety, performance, API ergonomics, and bundle size. Here's how Drizzle compares to the most popular alternatives in the TypeScript ecosystem:
| Feature | Drizzle ORM | Prisma | TypeORM | Sequelize |
|---|---|---|---|---|
| Type Safety | ✅ Full inference | ✅ Generated types | ⚠️ Partial | ❌ Manual types |
| Bundle Size | ~31 kB | ~1 MB+ (Rust engine) | ~500 kB | ~500 kB |
| Edge Runtime | ✅ Yes | ⚠️ Accelerate needed | ❌ No | ❌ No |
| Raw SQL Control | ✅ SQL-like API | ⚠️ Limited | ✅ Good | ✅ Good |
| Schema in code | ✅ TypeScript file | Prisma Schema file | ✅ Decorators/classes | ✅ JS/TS models |
| Migrations | Drizzle Kit CLI | Prisma Migrate CLI | Built-in CLI | Built-in CLI |
| Learning Curve | Low (SQL familiarity) | Low (great DX) | Medium | Medium |
Drizzle is the strongest choice when you need edge runtime compatibility, want to stay close to SQL, or need a minimal bundle. Prisma remains excellent when you prioritize a polished developer experience and don't need edge deployment. TypeORM and Sequelize are battle-tested but carry more legacy baggage.
Advanced Patterns
Transactions
// Drizzle transactions roll back automatically on throw
const result = await db.transaction(async (tx) => {
// All queries inside use the same transaction
const [user] = await tx
.insert(users)
.values({ name: 'Charlie', email: 'charlie@example.com' })
.returning();
await tx.insert(posts).values({
title: 'Welcome post',
content: 'My first post!',
authorId: user.id,
});
// Throw an error here to roll back both inserts
// throw new Error('Something went wrong');
return user;
});
Raw SQL Escape Hatch
import { sql } from 'drizzle-orm';
// Use the sql template tag for expressions not in the API
const results = await db
.select({
id: users.id,
name: users.name,
// Custom SQL expression — still type-safe via generic
nameLength: sql<number>`char_length(${users.name})`,
})
.from(users)
.where(sql`char_length(${users.name}) > 5`);
// Fully raw query (use sparingly)
const raw = await db.execute(
sql`SELECT * FROM users WHERE id = ${userId}`
);
Conclusion: When to Choose Drizzle ORM
Drizzle ORM has earned its place in the modern TypeScript toolkit. Its SQL-aligned API, near-zero bundle footprint, and first-class edge runtime support make it uniquely suited for the current landscape of full-stack TypeScript development — whether that's a Next.js App Router project, a Cloudflare Worker, or a standalone Node.js API.
Key Takeaways
- Schema-first type safety: Define once in TypeScript and get types throughout your codebase for free
- SQL-like API: If you know SQL, you can be productive with Drizzle in under an hour
- Tiny footprint: ~31 kB vs megabytes for alternatives — crucial for edge and serverless cold starts
- Drizzle Kit: Schema diff-based migrations keep your database and code in sync without friction
- Dual query modes: Use the builder for complex queries and the relational API for simple relation loading
- Transactions: First-class support with automatic rollback on error
- Raw SQL when needed: The
sqltemplate tag provides a typed escape hatch
Drizzle is a particularly natural fit if you're building a TypeScript full-stack application with tRPC, Zod, and a modern frontend framework — the entire data layer from database definition to API response can be fully typed end-to-end. Start with the schema, generate your first migration with drizzle-kit generate, and you'll immediately appreciate how little friction stands between your TypeScript code and your database.
"Drizzle is not trying to abstract SQL away — it's trying to make SQL as type-safe and ergonomic as possible in TypeScript. That's a very different and very welcome goal."