Comparing Drizzle ORM and Prisma: two type-safe TypeScript database tools with different philosophies, performance profiles, and developer experiences.
If you're choosing between Drizzle ORM and Prisma, you're deciding between two very different approaches to working with databases in TypeScript. Both offer type safety, but their philosophies, performance characteristics, and developer experiences diverge quite a bit.
Let's explore the differences in detail, with plenty of real-world code examples so you can feel the trade-offs for yourself.
schema.prisma
using Prisma's schema languageprisma migrate dev
→ SQL is generated under the hooddrizzle-kit
generates SQL migration files)await prisma.user.findMany({
where: { email: "test@example.com" },
include: { posts: true },
});
const users = await db
.select()
.from(users)
.where(eq(users.email, "test@example.com"));
You can find the benchmark here.
The benchmark image above shows a significant performance difference between Drizzle and Prisma. Drizzle consistently outperforms Prisma in raw query execution speed, especially in serverless environments where cold starts are critical. This is primarily due to Drizzle's lightweight architecture that doesn't rely on an external query engine. However, it's important to note that for most applications, the performance difference may not be noticeable unless you're dealing with high-traffic scenarios or have strict performance requirements. The developer experience and feature set might be more important considerations for many projects.
Feature | Prisma ⭐ | Drizzle 🌿 |
---|---|---|
Schema definition | Separate .prisma file | TypeScript code |
Migrations | Auto-generated, hidden SQL | SQL-first, transparent |
Query style | High-level client | SQL-like builder |
Type safety | From generated client | From TypeScript itself |
Performance | Rust engine (heavy in serverless) | Lightweight (pure TS) |
Ecosystem | Mature, polished | Growing, modern |
Beginner-friendly | ✅ Very easy | ⚠️ Requires SQL comfort |
Serverless-friendly | ❌ Cold start overhead | ✅ Great for serverless |
Community | Large, well-established | Fast-growing (esp. Next.js devs) |
Let's look at how to set up and use Prisma in a typical project:
datasource db { provider = "postgresql"; url = env("DATABASE_URL") }
generator client { provider = "prisma-client-js" }
model User {
id String @id @default(cuid())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
content String?
author User @relation(fields: [authorId], references: [id])
authorId String
tags PostTag[]
createdAt DateTime @default(now())
}
model Tag {
id String @id @default(cuid())
name String @unique
posts PostTag[]
}
model PostTag {
postId String
tagId String
post Post @relation(fields: [postId], references: [id])
tag Tag @relation(fields: [tagId], references: [id])
@@id([postId, tagId])
}
Generated Prisma client:
npx prisma generate
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
Create:
const user = await prisma.user.create({
data: { email: "a@b.co", name: "Alice" },
});
const post = await prisma.post.create({
data: { title: "Hello", content: "World", authorId: user.id },
});
Read with relations
const userWithPosts = await prisma.user.findUnique({
where: { email: "a@b.co" },
include: { posts: true },
});
const updated = await prisma.post.update({
where: { id: postId },
data: {
tags: {
connectOrCreate: tagNames.map((name) => ({
where: { name },
create: { name },
})),
},
},
include: { tags: true },
});
const updated = await prisma.post.update({
where: { id: postId },
data: { title: "New Title" },
});
await prisma.post.delete({ where: { id: postId } });
Prisma - cursor (preferred)
const page = await prisma.post.findMany({
take: 10,
skip: cursor ? 1 : 0,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { createdAt: "desc" },
});
await prisma.tag.upsert({
where: { name: "nextjs" },
update: {},
create: { name: "nextjs" },
});
await prisma.$transaction(async (tx) => {
const p = await tx.post.create({ data: { title, authorId } });
for (const name of tagNames) {
const t = await tx.tag.upsert({
where: { name },
update: {},
create: { name },
});
await tx.postTag.create({ data: { postId: p.id, tagId: t.id } });
}
});
const rows = await prisma.$queryRawUnsafe(`
WITH recent AS (
SELECT p.*, COUNT(*) AS likes7d
FROM posts p
JOIN likes l ON l.post_id = p.id AND l.created_at > now() - interval '7 days'
GROUP BY p.id
)
SELECT * FROM recent ORDER BY likes7d DESC LIMIT 10;
`);
const q = "nextjs";
const results = await prisma.$queryRaw`
SELECT id, title
FROM posts
WHERE to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))
@@ plainto_tsquery('english', ${q})
ORDER BY ts_rank(to_tsvector('english', title || ' ' || coalesce(content,'')),
plainto_tsquery('english', ${q})) DESC
LIMIT 20
`;
Now let's see how the same operations look with Drizzle ORM. You'll notice the SQL-first philosophy and TypeScript-native approach throughout.
With Drizzle, your schema is pure TypeScript. No need to learn a new schema language:
import { pgTable, text, timestamp, pgEnum } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const users = pgTable("users", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
email: text("email").notNull().unique(),
name: text("name"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const posts = pgTable("posts", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
title: text("title").notNull(),
content: text("content"),
authorId: text("author_id")
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const tags = pgTable("tags", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull().unique(),
});
export const postTags = pgTable("post_tags", {
postId: text("post_id")
.notNull()
.references(() => posts.id),
tagId: text("tag_id")
.notNull()
.references(() => tags.id),
});
// Define relations for type-safe queries
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 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],
}),
}));
Generate migrations:
npx drizzle-kit generate:pg
npx drizzle-kit push:pg
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const client = postgres(process.env.DATABASE_URL!);
const db = drizzle(client, { schema });
Create:
import { users, posts } from "./schema";
const [user] = await db
.insert(users)
.values({ email: "a@b.co", name: "Alice" })
.returning();
const [post] = await db
.insert(posts)
.values({ title: "Hello", content: "World", authorId: user.id })
.returning();
Read with relations:
import { eq } from "drizzle-orm";
const userWithPosts = await db.query.users.findFirst({
where: eq(users.email, "a@b.co"),
with: {
posts: true,
},
});
Many-to-many: attach tags
// First, create or find tags
const tagData = tagNames.map((name) => ({ name }));
const createdTags = await db
.insert(tags)
.values(tagData)
.onConflictDoNothing()
.returning();
// Find all tags (including existing ones)
const allTags = await db
.select()
.from(tags)
.where(inArray(tags.name, tagNames));
// Create post-tag relationships
await db.insert(postTags).values(
allTags.map((tag) => ({
postId: post.id,
tagId: tag.id,
})),
);
const [updated] = await db
.update(posts)
.set({ title: "New Title" })
.where(eq(posts.id, postId))
.returning();
await db.delete(posts).where(eq(posts.id, postId));
Drizzle makes cursor-based pagination intuitive:
import { desc, gt } from "drizzle-orm";
const page = await db
.select()
.from(posts)
.where(cursor ? gt(posts.id, cursor) : undefined)
.orderBy(desc(posts.createdAt))
.limit(10);
Offset-based pagination:
const page = await db
.select()
.from(posts)
.orderBy(desc(posts.createdAt))
.limit(10)
.offset(pageNumber * 10);
await db
.insert(tags)
.values({ name: "nextjs" })
.onConflictDoUpdate({
target: tags.name,
set: { name: "nextjs" },
});
await db.transaction(async (tx) => {
const [p] = await tx.insert(posts).values({ title, authorId }).returning();
for (const name of tagNames) {
const [t] = await tx
.insert(tags)
.values({ name })
.onConflictDoNothing()
.returning();
await tx.insert(postTags).values({ postId: p.id, tagId: t.id });
}
});
Drizzle gives you full SQL power when you need it:
import { sql } from "drizzle-orm";
const rows = await db.execute(sql`
WITH recent AS (
SELECT p.*, COUNT(*) AS likes7d
FROM posts p
JOIN likes l ON l.post_id = p.id AND l.created_at > now() - interval '7 days'
GROUP BY p.id
)
SELECT * FROM recent ORDER BY likes7d DESC LIMIT 10
`);
Or use the type-safe query builder for CTEs:
import { sql as sqlFunc } from "drizzle-orm";
const recent = db.$with("recent").as(
db
.select({
id: posts.id,
title: posts.title,
likes7d: sqlFunc<number>`COUNT(*)`.as("likes7d"),
})
.from(posts)
.innerJoin(likes, eq(likes.postId, posts.id))
.where(sqlFunc`${likes.createdAt} > now() - interval '7 days'`)
.groupBy(posts.id),
);
const results = await db
.with(recent)
.select()
.from(recent)
.orderBy(desc(recent.likes7d))
.limit(10);
import { sql } from "drizzle-orm";
const q = "nextjs";
const results = await db.execute(sql`
SELECT id, title
FROM posts
WHERE to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))
@@ plainto_tsquery('english', ${q})
ORDER BY ts_rank(to_tsvector('english', title || ' ' || coalesce(content,'')),
plainto_tsquery('english', ${q})) DESC
LIMIT 20
`);
Looking at both examples side by side, here are the key differences:
include
, connectOrCreate
)✅ You're building a traditional web app (not serverless-first)
✅ Your team prefers high-level abstractions over SQL
✅ You value mature tooling (Prisma Studio is excellent)
✅ You want maximum productivity for standard CRUD operations
✅ You need GraphQL integration (Prisma plays well with type-graphql, Pothos, etc.)
✅ You're building serverless/edge applications (Vercel, Cloudflare Workers)
✅ You want minimal bundle size and fast cold starts
✅ You're comfortable writing SQL and want full control
✅ You prefer everything in TypeScript (no separate schema language)
✅ You need to write complex queries regularly (CTEs, window functions, etc.)
In serverless environments, cold start time matters:
For query execution on the same database:
The cold start difference is the real game-changer for serverless applications.
Prisma migrations:
# Generate migration
npx prisma migrate dev --name add_tags
# Apply in production
npx prisma migrate deploy
Prisma generates SQL for you, but you can't easily customize it.
Drizzle migrations:
# Generate SQL migration files
npx drizzle-kit generate:pg
# You get actual SQL files you can review and modify
# Then apply them:
npx drizzle-kit push:pg
Drizzle gives you the SQL files directly - you can review, modify, and understand exactly what's happening to your database.
Prisma is not "worse" than Drizzle - they solve different problems:
For modern serverless applications built with Next.js 13+ App Router, Vercel Edge Functions, or Cloudflare Workers, Drizzle has clear advantages. The cold start time alone makes it worth considering.
For traditional Node.js applications, Prisma's mature ecosystem and developer experience might give you faster iteration speed.
My recommendation: Try both! They're both excellent tools. Start a small project with each and see which philosophy resonates with how you think about databases.
What's your experience? Have you tried both? Drop a comment below with your thoughts!
© 2025 Devhouse. All rights reserved.