Drizzle ORM Complete Guide | Schema, Queries, Migrations & TypeScript
이 글의 핵심
Drizzle ORM defines your schema in TypeScript (no .prisma files), generates zero-abstraction SQL, and has the smallest bundle size of any TS ORM. This guide covers schema design, queries, relations, and migrations with drizzle-kit.
Why Drizzle?
// Prisma approach — separate .prisma schema language
// model User {
// id Int @id @default(autoincrement())
// email String @unique
// }
// Drizzle approach — schema in TypeScript
import { pgTable, serial, text, varchar } 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(),
});
// The schema IS the types — no generation step needed for type safety
Drizzle advantages:
- Schema in TypeScript — one language, one file
- SQL-first — you see exactly what SQL runs
- Zero overhead — no query engine process
- Works everywhere — Node.js, serverless, edge, Bun, Deno
- Smallest bundle (~30KB vs Prisma’s ~10MB)
Setup
# PostgreSQL
npm install drizzle-orm pg
npm install -D drizzle-kit @types/pg
# Or with postgres.js (no types needed)
npm install drizzle-orm postgres
npm install -D drizzle-kit
// drizzle.config.ts
import type { Config } from 'drizzle-kit';
export default {
schema: './src/db/schema.ts', // Your schema file(s)
out: './drizzle', // Migration output directory
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config;
Schema Definition
// src/db/schema.ts
import {
pgTable, serial, text, varchar, integer, boolean,
timestamp, pgEnum, uniqueIndex, index
} from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
// Enum
export const roleEnum = pgEnum('role', ['admin', 'editor', 'viewer']);
// Users table
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: text('name').notNull(),
role: roleEnum('role').default('viewer').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
emailIdx: uniqueIndex('users_email_idx').on(table.email),
}));
// Posts table
export const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: varchar('title', { length: 500 }).notNull(),
content: text('content'),
published: boolean('published').default(false).notNull(),
authorId: integer('author_id').notNull().references(() => users.id, {
onDelete: 'cascade',
}),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
}, (table) => ({
authorIdx: index('posts_author_idx').on(table.authorId),
publishedIdx: index('posts_published_idx').on(table.published, table.createdAt),
}));
// Tags table
export const tags = pgTable('tags', {
id: serial('id').primaryKey(),
name: varchar('name', { length: 100 }).notNull().unique(),
});
// Many-to-many join table
export const postTags = pgTable('post_tags', {
postId: integer('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
tagId: integer('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }),
});
// Relations (for ORM-style queries with .with())
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
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] }),
}));
Database Connection
// src/db/index.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const client = postgres(process.env.DATABASE_URL!);
export const db = drizzle(client, { schema });
// Infer types from schema
export type User = typeof schema.users.$inferSelect;
export type NewUser = typeof schema.users.$inferInsert;
export type Post = typeof schema.posts.$inferSelect;
export type NewPost = typeof schema.posts.$inferInsert;
CRUD Queries
Insert
import { db } from './db';
import { users, posts } from './db/schema';
// Insert one
const [user] = await db.insert(users).values({
email: '[email protected]',
name: 'Alice',
role: 'admin',
}).returning(); // Returns the created row(s)
// Insert many
await db.insert(users).values([
{ email: '[email protected]', name: 'Bob' },
{ email: '[email protected]', name: 'Carol' },
]);
// Upsert (insert or update on conflict)
await db.insert(users).values({
email: '[email protected]',
name: 'Alice Updated',
}).onConflictDoUpdate({
target: users.email,
set: { name: 'Alice Updated' },
});
// Upsert — do nothing on conflict
await db.insert(tags).values({ name: 'typescript' })
.onConflictDoNothing();
Select
import { eq, and, or, gte, lte, like, desc, asc, count, sql } from 'drizzle-orm';
// Select all
const allUsers = await db.select().from(users);
// Select specific fields
const userNames = await db.select({
id: users.id,
name: users.name,
}).from(users);
// Where conditions
const adminUsers = await db.select()
.from(users)
.where(eq(users.role, 'admin'));
const recentPublishedPosts = await db.select()
.from(posts)
.where(
and(
eq(posts.published, true),
gte(posts.createdAt, new Date('2026-01-01'))
)
)
.orderBy(desc(posts.createdAt))
.limit(10)
.offset(0);
// Search with LIKE
const searchResults = await db.select()
.from(posts)
.where(like(posts.title, '%typescript%'));
// Join
const postsWithAuthors = await db.select({
postId: posts.id,
title: posts.title,
authorName: users.name,
authorEmail: users.email,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt));
Relations (with)
// Use .query for ORM-style nested queries (requires relations config)
const userWithPosts = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
posts: {
where: eq(posts.published, true),
orderBy: desc(posts.createdAt),
limit: 5,
},
},
});
// userWithPosts.posts is typed as Post[]
const allUsersWithPostCount = await db.query.users.findMany({
with: {
posts: true,
},
});
Update
// Update with returning
const [updated] = await db.update(users)
.set({ name: 'Alice Smith', updatedAt: new Date() })
.where(eq(users.id, 1))
.returning();
// Atomic increment using sql template
await db.update(posts)
.set({ viewCount: sql`${posts.viewCount} + 1` })
.where(eq(posts.id, 1));
Delete
await db.delete(users).where(eq(users.id, 1));
// Delete with returning
const [deleted] = await db.delete(posts)
.where(eq(posts.id, 1))
.returning();
Transactions
// Atomic transaction
const result = await db.transaction(async (tx) => {
// All operations use tx instead of db
const [newUser] = await tx.insert(users).values({
email: '[email protected]',
name: 'Alice',
}).returning();
await tx.insert(posts).values({
title: 'First Post',
content: 'Hello!',
authorId: newUser.id,
});
return newUser;
});
// If any operation throws, the entire transaction rolls back
Raw SQL
import { sql } from 'drizzle-orm';
// Type-safe raw query
const result = await db.execute<{ id: number; name: string }>(
sql`SELECT id, name FROM users WHERE created_at > ${new Date('2026-01-01')}`
);
// Use raw SQL in queries
const usersWithCount = await db.select({
user: users,
postCount: sql<number>`count(${posts.id})`,
})
.from(users)
.leftJoin(posts, eq(users.id, posts.authorId))
.groupBy(users.id);
Migrations with drizzle-kit
# Generate migration from schema changes
npx drizzle-kit generate
# Output: drizzle/0001_add_posts_view_count.sql
# ALTER TABLE posts ADD COLUMN view_count INTEGER DEFAULT 0 NOT NULL;
# Apply migrations (development)
npx drizzle-kit migrate
# Push schema directly (development only — no migration files)
npx drizzle-kit push
# Open Drizzle Studio (visual DB browser)
npx drizzle-kit studio
Edge / Serverless (Neon + Cloudflare)
// Neon (PostgreSQL serverless) — works in edge environments
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
// Cloudflare D1 (SQLite at the edge)
import { drizzle } from 'drizzle-orm/d1';
export function createDB(d1: D1Database) {
return drizzle(d1);
}
// Cloudflare Workers handler
export default {
async fetch(request: Request, env: Env) {
const db = createDB(env.DB);
const users = await db.select().from(usersTable);
return Response.json(users);
}
};
Type Inference
// Infer types directly from schema — no duplication
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
type User = InferSelectModel<typeof users>;
// { id: number; email: string; name: string; role: "admin" | "editor" | "viewer"; createdAt: Date; }
type NewUser = InferInsertModel<typeof users>;
// { email: string; name: string; role?: "admin" | "editor" | "viewer"; createdAt?: Date; }
// Or use the $ shorthand
type User = typeof users.$inferSelect;
type NewUser = typeof users.$inferInsert;
Drizzle vs Prisma Quick Comparison
| Feature | Drizzle | Prisma |
|---|---|---|
| Schema language | TypeScript | Prisma DSL (.prisma) |
| Bundle size | ~30KB | ~10MB |
| Edge support | ✅ Native | ⚠️ Limited (Data Proxy) |
| Migration tool | drizzle-kit | prisma migrate |
| Visual Studio | drizzle studio | Prisma Studio |
| Relations | Explicit joins or .with() | .include(), .select() |
| Raw SQL | sql template tag | $queryRaw |
| Type safety | Full | Full |
Related posts: