What is Drizzle ORM?
Drizzle ORM is a TypeScript-first Object-Relational Mapping library designed to provide maximum type safety with zero code generation overhead. Unlike traditional ORMs that rely on decorators or runtime reflection, Drizzle leverages TypeScript's type inference to build fully typed database schemas, queries, and results at compile time. It supports PostgreSQL, MySQL, SQLite, and other SQL-based databases through dedicated driver packages.
The core philosophy of Drizzle is simple: stay as close to raw SQL as possible while giving you the full power of TypeScript's type system. Every table definition, every query, every result row is inferred with precise types — no manual type casting, no type assertions, no guesswork. The schema definitions themselves serve as both the source of truth for your database structure and the foundation for TypeScript's type narrowing across your entire application.
Key Features at a Glance
- Zero code generation — types are inferred directly from schema objects
- Full TypeScript inference for selects, inserts, updates, and joins
- SQL-like query syntax that feels familiar and expressive
- Automatic migration generation from schema changes
- First-class relational query support with nested result typing
- Lightweight and performant with minimal runtime overhead
Why Strong Typing in Database Operations Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →In a typical Node.js backend, the database layer is often the largest source of runtime errors. Misspelled column names, incorrect type assumptions, or mismatched join conditions can cause subtle bugs that only surface in production. Drizzle ORM addresses this by making your database interactions fully type-checked at compile time. The compiler becomes your first line of defense against invalid queries.
The Cost of Untyped Database Access
Consider a raw SQL query executed via a generic driver like pg or mysql2:
const result = await pool.query('SELECT id, name, email FROM users WHERE active = $1', [true]);
// result.rows is typed as any[] — no guarantees about shape or field types
const userName = result.rows[0].name; // TypeScript trusts you, but did you spell 'name' correctly?
With Drizzle, the equivalent query looks like this:
const users = await db.select()
.from(usersTable)
.where(eq(usersTable.active, true));
// users is automatically typed as Array<{ id: number; name: string; email: string; active: boolean; }>
// Accessing users[0].name is fully safe — TypeScript knows it exists and is a string
The difference is profound. No more defensive type guards, no more optional chaining on fields that definitely exist, and no more unit tests dedicated solely to verifying query shapes. Your application logic can focus on business rules instead of data validation.
Type Safety Across the Full Stack
Drizzle's type inference propagates through your entire application. When you extract a query into a service function, the return type is automatically derived. When you pass that data to a controller or API handler, the types remain intact. This creates a type-safe pipeline from database to client, reducing the need for runtime validation middleware and enabling more confident refactoring.
Getting Started: Setting Up Drizzle ORM
Let's walk through a complete setup for a PostgreSQL-backed TypeScript project. The process is similar for MySQL and SQLite — you simply swap the driver package.
Installation
Start by installing the core Drizzle package, the PostgreSQL driver, and the migration utilities:
npm install drizzle-orm drizzle-kit pg
npm install --save-dev @types/pg
The drizzle-kit package provides CLI tools for schema introspection, migration generation, and pushing changes to your database. The pg package is the native PostgreSQL driver for Node.js.
Database Connection Setup
Create a dedicated file for your database connection. This keeps the setup centralized and reusable across your project:
// src/db/connection.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
});
export const db = drizzle(pool);
The drizzle() function wraps the connection pool and returns a typed database instance. At this point, the instance knows nothing about your schema yet — that comes next.
Defining Your First Schema
Schemas in Drizzle are plain TypeScript objects. You define tables using helper functions like pgTable (for PostgreSQL), mysqlTable, or sqliteTable. Each column is defined with its SQL type and optional constraints:
// src/db/schema/users.ts
import { pgTable, serial, varchar, boolean, timestamp } from 'drizzle-orm/pg-core';
export const usersTable = pgTable('users', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 100 }).notNull(),
email: varchar('email', { length: 255 }).notNull().unique(),
active: boolean('active').default(true).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});
Notice that each column function receives the actual SQL column name as its first argument. This decouples your TypeScript property names from the database column names, allowing you to follow idiomatic conventions on both sides. The TypeScript type of usersTable.id is automatically inferred as a column that holds numbers and corresponds to the id column in PostgreSQL.
Schema Validation with Zod (Optional but Recommended)
For additional runtime validation on insert and update payloads, Drizzle integrates seamlessly with Zod:
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { z } from 'zod';
export const insertUserSchema = createInsertSchema(usersTable, {
email: (schema) => schema.email.email(),
name: (schema) => schema.name.min(2).max(100),
});
export const selectUserSchema = createSelectSchema(usersTable);
The createInsertSchema and createSelectSchema functions automatically generate Zod schemas that match your table definitions. You can override or extend validations for specific columns, as shown with the email and name refinements above.
Writing Typed Queries
Drizzle's query API mirrors SQL syntax closely while providing full type inference. Every clause you chain narrows the result type appropriately.
Basic SELECT Queries
// Select all active users — returns Array<{ id: number; name: string; email: string; ... }>
const activeUsers = await db.select()
.from(usersTable)
.where(eq(usersTable.active, true));
// Select only specific columns — returns Array<{ id: number; name: string }>
const userNames = await db.select({
id: usersTable.id,
name: usersTable.name,
}).from(usersTable);
The second example demonstrates column selection with a custom shape. The result type is narrowed to exactly Array<{ id: number; name: string }>. There is no over-fetching and no ambiguity about which fields are available on the returned objects.
INSERT, UPDATE, and DELETE Operations
// Insert a single user — returns the inserted row with all defaults applied
const newUser = await db.insert(usersTable)
.values({
name: 'Alice Johnson',
email: 'alice@example.com',
})
.returning();
// TypeScript knows newUser has all columns, including auto-generated id and timestamps
console.log(newUser.id); // number, guaranteed to exist
// Bulk insert with type-checked array
const newUsers = await db.insert(usersTable)
.values([
{ name: 'Bob Smith', email: 'bob@example.com' },
{ name: 'Carol White', email: 'carol@example.com' },
])
.returning();
// Update with selective field assignment
await db.update(usersTable)
.set({ active: false, updatedAt: new Date() })
.where(eq(usersTable.id, 5));
// Delete with condition — TypeScript prevents you from forgetting .where()
await db.delete(usersTable)
.where(eq(usersTable.email, 'bob@example.com'));
The .returning() method on inserts and updates returns the affected rows with their current database state, fully typed. This is particularly useful for retrieving auto-generated values like IDs and timestamps without making a separate query.
Advanced Filtering and Conditions
Drizzle provides a rich set of filter operators that compose naturally:
import { eq, ne, gt, gte, lt, lte, like, ilike, inArray, and, or, isNull } from 'drizzle-orm';
const filteredUsers = await db.select()
.from(usersTable)
.where(
and(
eq(usersTable.active, true),
or(
ilike(usersTable.email, '%@gmail.com'),
ilike(usersTable.email, '%@outlook.com')
),
gte(usersTable.createdAt, new Date('2024-01-01'))
)
);
All filter functions are fully typed — they only accept columns of matching types. You cannot accidentally compare a string column against a number or use ilike (case-insensitive pattern matching) on a numeric column. The compiler catches these mistakes immediately.
Relations and Nested Queries
One of Drizzle's most powerful features is its relational query API, which allows you to fetch related data in a single query while maintaining full type safety through the nested results.
Defining Relations Between Tables
First, let's create a related table and define the relationship:
// src/db/schema/posts.ts
import { pgTable, serial, integer, text, timestamp } from 'drizzle-orm/pg-core';
import { usersTable } from './users';
import { relations } from 'drizzle-orm';
export const postsTable = pgTable('posts', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => usersTable.id),
title: text('title').notNull(),
content: text('content').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
// Define the relation from posts to users
export const postsRelations = relations(postsTable, ({ one }) => ({
author: one(usersTable, {
fields: [postsTable.userId],
references: [usersTable.id],
}),
}));
// Define the inverse relation from users to posts
export const usersRelations = relations(usersTable, ({ many }) => ({
posts: many(postsTable),
}));
The relations() function creates metadata that Drizzle uses at query time to construct joins and shape nested results. These relation definitions are purely TypeScript objects — they don't affect the database schema itself, which is already defined by the table definitions and foreign key constraints.
Fetching Nested Relational Data
Drizzle offers two distinct APIs for querying relations, each with different use cases and type inference behavior.
Using db.query() — The Simplified Relational API
The db.query() method requires you to pass all your table and relation definitions to the drizzle() constructor upfront. This approach gives you a clean, high-level API:
// Updated connection setup with schema awareness
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 });
// Now you can use db.query.users.findMany() with automatic relation inclusion
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
where: (users, { eq }) => eq(users.active, true),
});
// The result type is automatically:
// Array<{
// id: number;
// name: string;
// email: string;
// active: boolean;
// createdAt: Date;
// updatedAt: Date;
// posts: Array<{
// id: number;
// userId: number;
// title: string;
// content: string;
// createdAt: Date;
// }>;
// }>
The with clause accepts a shape object where keys match your relation names. TypeScript infers the complete nested structure, so accessing usersWithPosts[0].posts[0].title is fully type-checked. You can nest relations arbitrarily deep when your schema supports it.
Using db.select() with Nested Relations
For more granular control over the shape of returned data, use the db.select() API with relation helpers. This allows you to pick exactly which columns and relations to include:
const result = await db.select({
userName: usersTable.name,
postTitle: postsTable.title,
postContent: postsTable.content,
})
.from(usersTable)
.innerJoin(postsTable, eq(usersTable.id, postsTable.userId))
.where(eq(usersTable.active, true));
// result is typed as Array<{ userName: string; postTitle: string; postContent: string }>
This flat join approach gives you complete control over the output shape. The type inference is precise — only the fields you explicitly select appear in the result type.
Migrations and Schema Evolution
Drizzle Kit (drizzle-kit) handles database migrations with a declarative workflow. You define your schema in TypeScript, and Drizzle Kit generates SQL migration files that transform your database to match.
Configuring Drizzle Kit
Create a drizzle.config.ts file at the root of your project:
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema/*.ts',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});
The schema field points to your table definition files using a glob pattern. The out directory is where migration SQL files will be generated. Setting strict: true ensures that Drizzle Kit will alert you about destructive changes like column drops that could cause data loss.
Generating and Running Migrations
The typical migration workflow involves three steps:
# Step 1: Generate migration SQL from schema changes
npx drizzle-kit generate
# Step 2: Inspect the generated SQL file (example: src/db/migrations/0001_awesome_phantom.sql)
# Step 3: Apply migrations to your database
npx drizzle-kit migrate
For production deployments, you can run migrations programmatically using the migrator utility:
// src/db/migrate.ts
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { db } from './connection';
async function runMigrations() {
await migrate(db, {
migrationsFolder: './src/db/migrations',
});
console.log('Migrations applied successfully');
process.exit(0);
}
runMigrations().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
This approach integrates well with CI/CD pipelines and containerized environments where you want migrations to run automatically on startup.
Best Practices for Drizzle ORM Projects
Over time, teams using Drizzle have converged on several patterns that maximize the benefits of strong typing while keeping codebases maintainable.
1. Organize Schemas by Domain
Rather than dumping all table definitions into a single file, group them by domain context:
src/db/
schema/
users.ts — usersTable, usersRelations
posts.ts — postsTable, postsRelations
comments.ts — commentsTable, commentsRelations
index.ts — barrel export of all tables and relations
connection.ts — database instance
migrations/ — auto-generated SQL files
A barrel file (index.ts) re-exports everything so consumers can import from a single path:
// src/db/schema/index.ts
export * from './users';
export * from './posts';
export * from './comments';
2. Use Dedicated Query Functions for Complex Operations
Extract frequently used queries into typed helper functions. This creates a clean data access layer and allows TypeScript to propagate return types automatically:
// src/services/userService.ts
import { db } from '../db/connection';
import { usersTable, postsTable } from '../db/schema';
import { eq, and, desc } from 'drizzle-orm';
export async function getUserWithRecentPosts(userId: number) {
return await db.query.users.findFirst({
where: (users, { eq }) => eq(users.id, userId),
with: {
posts: {
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
limit: 10,
},
},
});
}
// The return type is inferred as:
// {
// id: number; name: string; email: string; ...
// posts: Array<{ id: number; title: string; ... }>
// } | undefined
Notice that TypeScript correctly infers | undefined in the return type because findFirst may not find a matching row. Callers of this function are forced to handle the null case, eliminating a common class of runtime errors.
3. Leverage Partial Selects for API Responses
When building REST or GraphQL endpoints, select only the fields the client needs. Drizzle's type system ensures that your API response types match exactly what you query:
// GET /api/users — returns only public-safe fields
export async function getPublicUserProfiles() {
return await db.select({
id: usersTable.id,
displayName: usersTable.name,
avatarUrl: usersTable.email, // simplified for example
}).from(usersTable)
.where(eq(usersTable.active, true));
}
// The return type is precisely Array<{ id: number; displayName: string; avatarUrl: string }>
// TypeScript guarantees no sensitive fields (like internal flags) leak into the response
4. Keep Migrations Version-Controlled
Always commit generated migration files to version control. They serve as an auditable history of your database schema changes and enable reproducible deployments. Never edit generated migration files manually — if you need custom SQL logic in a migration, use Drizzle Kit's custom migration feature or write a separate manual migration.
5. Use Transactions for Consistency-Critical Operations
Drizzle supports transactions with full type safety. The transaction object carries the same schema awareness as the main db instance:
import { db } from './connection';
import { usersTable, postsTable } from './schema';
async function createUserWithFirstPost(name: string, email: string, postTitle: string) {
return await db.transaction(async (tx) => {
const [newUser] = await tx.insert(usersTable)
.values({ name, email })
.returning();
const [newPost] = await tx.insert(postsTable)
.values({
userId: newUser.id,
title: postTitle,
content: 'Welcome post',
})
.returning();
return { user: newUser, post: newPost };
});
}
// The return type is { user: User; post: Post } — fully typed through the transaction boundary
If any query within the transaction fails, all changes are rolled back. The tx object has the exact same typed API as db, so you lose none of the type safety when working inside transactions.
6. Combine with Zod for Input Validation at the Boundary
Use drizzle-zod to generate validation schemas for inserts and updates, then validate incoming data at your API layer before it reaches your query functions:
import { createInsertSchema } from 'drizzle-zod';
import { z } from 'zod';
const insertUserSchema = createInsertSchema(usersTable, {
email: (col) => col.email.email(),
});
// In your API route handler
app.post('/users', async (req, res) => {
const parsed = insertUserSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ errors: parsed.error.flatten() });
}
const newUser = await db.insert(usersTable)
.values(parsed.data)
.returning();
res.status(201).json(newUser);
});
This pattern creates a type-safe bridge between the untyped boundary of HTTP input and the strongly typed database layer. Zod validates and coerces the raw input, and Drizzle ensures the validated data matches the database schema exactly.
Working with PostgreSQL-Specific Features
Drizzle provides first-class support for PostgreSQL-specific capabilities like JSON columns, full-text search, and array types. These features are fully typed, so you get compile-time safety even with complex column types.
Typed JSON Columns
import { pgTable, serial, jsonb, varchar } from 'drizzle-orm/pg-core';
interface UserPreferences {
theme: 'light' | 'dark';
notifications: boolean;
language: string;
}
export const userProfilesTable = pgTable('user_profiles', {
id: serial('id').primaryKey(),
userId: varchar('user_id').notNull(),
preferences: jsonb('preferences').$type().notNull(),
});
// When querying, preferences is typed as UserPreferences, not as unknown
const profiles = await db.select()
.from(userProfilesTable)
.where(eq(userProfilesTable.userId, 'abc123'));
// profiles[0].preferences.theme — TypeScript knows this is 'light' | 'dark'
The .$type method tells TypeScript the shape of the JSON data. Drizzle stores and retrieves it as a native PostgreSQL jsonb column, but your application code interacts with it as a typed object.
Full-Text Search Vectors
import { pgTable, text, tsvector } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const articlesTable = pgTable('articles', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
body: text('body').notNull(),
searchVector: tsvector('search_vector'),
});
// Populate the tsvector column with weighted text
await db.insert(articlesTable)
.values({
title: 'Drizzle ORM Guide',
body: 'A comprehensive guide to using Drizzle ORM...',
searchVector: sql`setweight(to_tsvector('english', ${'Drizzle ORM Guide'}), 'A') || setweight(to_tsvector('english', ${'A comprehensive guide...'}), 'B')`,
})
.returning();
// Query using full-text search with ranking
const searchResults = await db.select({
id: articlesTable.id,
title: articlesTable.title,
rank: sql`ts_rank(${articlesTable.searchVector}, plainto_tsquery('english', ${'orm guide'}))`.as('rank'),
})
.from(articlesTable)
.where(sql`${articlesTable.searchVector} @@ plainto_tsquery('english', ${'orm guide'})`)
.orderBy(sql`rank DESC`);
The sql template literal allows you to embed raw SQL expressions while maintaining type safety. The .as('rank') method gives the computed expression a typed alias that appears in the result type.
Performance Considerations
Drizzle is designed with performance as a primary goal. It generates SQL queries directly from your chained method calls without intermediate AST representations or runtime query builders. The library itself has a small footprint (approximately 10KB gzipped for the core) and adds negligible overhead to query execution.
Query Execution Model
Every Drizzle query method returns a Promise that resolves to the typed result. There is no lazy evaluation, no change tracking, and no entity state management — Drizzle is purely a query builder and executor. When you call await db.select()..., it builds the SQL string immediately, executes it against the driver, and returns the typed rows. This direct model means performance is bounded primarily by your database and driver, not by the ORM layer.
Connection Pooling
Drizzle delegates connection management entirely to the underlying driver (like pg's Pool or mysql2's pool). You configure pooling at the driver level, and Drizzle uses the pool transparently. This separation of concerns lets you tune connection parameters independently of query logic.
Comparison with Other TypeScript ORMs
Understanding how Drizzle differs from alternatives helps clarify when it's the right choice for your project.
Drizzle vs. Prisma
- Schema definition: Prisma uses a declarative
.prismaschema file with its own DSL. Drizzle uses pure TypeScript, so your schema lives in the same language as your application logic. - Code generation: Prisma generates TypeScript types from the schema file. Drizzle infers types directly from schema objects — no generation step.
- Query API: Prisma has a higher-level ORM-style API. Drizzle offers a SQL-adjacent API that gives you finer control over the generated queries.
- Bundle size: Prisma's generated client is typically larger. Drizzle's runtime is minimal.
Drizzle vs. TypeORM
- Approach: TypeORM uses decorators and an active-record pattern. Drizzle is purely functional with no decorators or classes required.
- Type safety: TypeORM's typing relies heavily on manual generics and optional type hints. Drizzle infers types automatically.
- Migrations: TypeORM generates migrations by diffing the database. Drizzle generates them from schema declarations.
Drizzle vs. Kysely
- Type inference: Kysely requires you to define a
Databaseinterface manually for type safety. Drizzle infers it from table definitions. - Schema management: Kysely focuses solely on query building. Drizzle includes schema definition and migration tooling.
- Learning curve: Both are SQL-adjacent and lightweight. Drizzle adds schema definition conveniences on top of a Kysely-like query builder.
Conclusion
Drizzle ORM represents a compelling evolution in TypeScript database tooling. By treating schema definitions as first-class TypeScript objects and leveraging the compiler's type inference engine, it eliminates the friction between database schemas and application types. You write your tables once — in TypeScript — and get automatic, precise typing across every query, insert, update, and relation in your application.
The result is a development experience where database errors are caught at compile time rather than in production logs. Refactoring becomes safer because renaming a column in your schema definition immediately surfaces every affected query through TypeScript errors. The close-to-SQL query API means you retain the full expressiveness of your database while gaining the safety net of static typing.
Whether you're building a small side project or a large-scale production system, Drizzle's combination of type safety, performance, and developer ergonomics makes it a strong candidate for any TypeScript application that interacts with a SQL database. The zero-code-generation philosophy keeps your build pipeline simple, and the first-class migration tooling ensures your database stays synchronized with your evolving application code.