Drizzle ORM Complete Guide | Schema, Queries, Migrations & TypeScript

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

FeatureDrizzlePrisma
Schema languageTypeScriptPrisma DSL (.prisma)
Bundle size~30KB~10MB
Edge support✅ Native⚠️ Limited (Data Proxy)
Migration tooldrizzle-kitprisma migrate
Visual Studiodrizzle studioPrisma Studio
RelationsExplicit joins or .with().include(), .select()
Raw SQLsql template tag$queryRaw
Type safetyFullFull

Related posts: