I replaced Prisma with Drizzle. Six months later, here’s what I gained and what I missed.
About eight months ago, I started seriously questioning Prisma. Not because it was broken, but because the friction had been accumulating…
I replaced Prisma with Drizzle. Six months later, here’s what I gained and what I missed.
About eight months ago, I started seriously questioning Prisma. Not because it was broken, but because the friction had been accumulating for a while and I finally sat down to name it. I have multiple small apps running: PostgreSQL backend, TypeScript throughout, a handful of services behind an API. Nothing exotic. And Prisma was doing its job. But a few things kept nagging.
The generated @prisma/client was enormous. On serverless functions, cold starts were noticeably slow because the client had to load. The prisma generate step during CI felt like a tax we paid on every merge. And whenever we needed to write a raw query for something complex, the result came back as unknown[] with no typing help whatsoever. We had to manually cast everything.
So I migrated to Drizzle ORM. I finished the switch about six months ago. Here is the honest account.
Why I looked at Drizzle in the first place
The bundle size problem was the first thing that made us take Drizzle seriously. At the time I migrated, our Prisma client was contributing roughly 14MB to our Lambda deployment bundle (the Rust engine binary alone was several megabytes). Cold starts on infrequently-hit endpoints were running 2 to 3 seconds. That is the kind of thing you notice in latency graphs.
Drizzle ships at around 7.4KB, minified and gzipped, with zero runtime dependencies. No binary. No separate engine process. No serialization layer between your TypeScript and the database connection.
The other friction point was the generation step. Every schema change required running prisma generate before TypeScript would pick up the new types. In practice, this meant either running it manually and forgetting to commit the updated client, or baking it into every CI step and watching it add 15 to 20 seconds to builds. Neither option felt good.
Drizzle’s schema is TypeScript. Change the schema file, and your types are already updated. There is no generation step. The compiler sees the changes immediately.
The raw query situation was the third push. Prisma treats raw SQL as an escape hatch. You call $queryRaw and get back unknown[]. You then have to either cast the result or write a Zod schema to validate it. Drizzle's sql template tag infers the type you declare: sql<{ id: number; total: number }> gives you back exactly that type. No casting, no validation wrapper.
What the migration looked like
I’ll take three tables as example: users, products, and orders. In Prisma, these lived in a schema.prisma file:
model User {
id String @id @default(uuid())
email String @unique
name String
orders Order[]
createdAt DateTime @default(now())
}
model Product {
id String @id @default(uuid())
name String
priceInCents Int
orders OrderItem[]
}
model Order {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
items OrderItem[]
createdAt DateTime @default(now())
}
model OrderItem {
id String @id @default(uuid())
orderId String
order Order @relation(fields: [orderId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
quantity Int
}
In Drizzle, the equivalent schema is a TypeScript file:
import { pgTable, uuid, text, integer, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';\
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const products = pgTable('products', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
priceInCents: integer('price_in_cents').notNull(),
});
export const orders = pgTable('orders', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const orderItems = pgTable('order_items', {
id: uuid('id').defaultRandom().primaryKey(),
orderId: uuid('order_id').notNull().references(() => orders.id),
productId: uuid('product_id').notNull().references(() => products.id),
quantity: integer('quantity').notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
orders: many(orders),
}));
export const ordersRelations = relations(orders, ({ one, many }) => ({
user: one(users, { fields: [orders.userId], references: [users.id] }),
items: many(orderItems),
}));
export const orderItemsRelations = relations(orderItems, ({ one }) => ({
order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }),
product: one(products, { fields: [orderItems.productId], references: [products.id] }),
}));
More lines? Yes. But it is TypeScript all the way down. Your IDE knows every field. Autocomplete works on the schema itself. You can import schema pieces into application code and use them directly.
The drizzle.config.ts file ties everything together:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/schema.ts',
out: './drizzle/migrations',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
strict: true,
});
Watch out: Always set
strict: true. Without it, Drizzle can interpret a column rename as "drop the old column, add a new one." You will lose data and not understand why until you check the generated SQL. Withstrict: true, Drizzle stops and asks for confirmation on any ambiguous change.
Once the schema was written, generating migrations was one command:
bunx drizzle-kit generate
This creates a SQL file in ./drizzle/migrations. You read it, verify it looks right, then apply it:
bunx drizzle-kit migrate
That SQL file goes into version control alongside your schema. The migration history is transparent SQL, not a log that only makes sense inside Prisma’s tooling.
What got better immediately
Bundle size and cold starts. This one was immediate and measurable. Our Lambda bundles dropped by over 90%. Functions that were taking 2+ seconds to cold start were down to under 400ms. For our background processing jobs, this had no effect. For our API functions, it was noticeable.
Raw SQL with types. This was the change we used on the second day. We had a reporting query that joined four tables with aggregations that the Prisma query builder could not express cleanly. In Prisma, the raw fallback looked like this:
// Prisma - returns unknown[], no type safety
const revenue = await prisma.$queryRaw`
SELECT p.id, p.name, SUM(oi.quantity * p.price_in_cents) as total_revenue
FROM products p
JOIN order_items oi ON p.id = oi.product_id
GROUP BY p.id, p.name
ORDER BY total_revenue DESC
`;
In Drizzle, the same query has a type:
import { sql } from 'drizzle-orm';
interface ProductRevenue {
id: string;
name: string;
totalRevenue: number;
}
const revenue = await db.execute(sql<ProductRevenue>`
SELECT p.id, p.name, SUM(oi.quantity * p.price_in_cents) AS total_revenue
FROM products p
JOIN order_items oi ON p.id = oi.product_id
GROUP BY p.id, p.name
ORDER BY total_revenue DESC
`);
// revenue.rows typed as ProductRevenue[] at compile-time; validate at runtime if needed
You still declare the type manually instead of having it inferred from the query, but that is a small trade-off for not casting unknown everywhere.
Drizzle Studio. Running bunx drizzle-kit studio opens a local database browser. It is not as polished as Prisma Studio, but it works, it is fast, and it does not require a Prisma account or any cloud connection. For local development, that matters.
What took adjustment
Relation queries are more explicit. With Prisma, loading an order with its user and items looked like this:
// Prisma - nested include
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
user: true,
items: {
include: { product: true }
}
}
});
With Drizzle’s relational API, you write the equivalent as:
import { db } from './db';
import { orders, users, orderItems, products } from './schema';
const order = await db.query.orders.findFirst({
where: (orders, { eq }) => eq(orders.id, orderId),
with: {
user: true,
items: {
with: { product: true }
}
}
});
The shape is similar. But the with API requires you to have set up the relations() declarations in your schema (which we showed earlier). When we first migrated, we had not set those up completely and spent an afternoon confused about why with was not working. Once you have the relations defined, it clicks. But that first-time setup is something Prisma handles for you automatically from the schema.
Seeding is manual. Prisma has a seeding convention: add a seed.ts script, run prisma db seed, and it works. Drizzle has no built-in seed command. You write a plain TypeScript file and run it yourself:
import { db } from './db';
import { users, products } from './schema';
async function seed() {
await db.insert(users).values([
{ email: 'ana.silva@acme.com', name: 'Ana Silva' },
{ email: 'carlos.reyes@acme.com', name: 'Carlos Reyes' },
]);
await db.insert(products).values([
{ name: 'Starter Plan', priceInCents: 2900 },
{ name: 'Pro Plan', priceInCents: 7900 },
{ name: 'Enterprise Plan', priceInCents: 19900 },
]);
console.log('Seed complete');
process.exit(0);
}
seed().catch(console.error);
Then in package.json:
{
"scripts": {
"db:seed": "bun run src/seed.ts"
}
}
This is not a big deal. It is more transparent than Prisma’s convention. But if you had scripts that relied on prisma db seed, you will need to port them.
Drizzle Studio vs Prisma Studio. Drizzle Studio works. Prisma Studio looks better. Prisma Studio has filter UI, pagination controls, and a polished table editor. Drizzle Studio is functional but feels sparse. Six months in, we use both less than we expected to, because most data exploration happens in a proper database client anyway.
The one scenario where Prisma is still better
Nested mutations. If you need to create an order and its line items in a single operation, Prisma makes this ergonomic:
// Prisma - nested create in one call
const order = await prisma.order.create({
data: {
userId: user.id,
items: {
create: [
{ productId: starterPlanId, quantity: 1 },
{ productId: proPlanId, quantity: 2 },
]
}
},
include: { items: true }
});
Drizzle does not support nested creates. You handle this yourself with an explicit transaction:
import { db } from './db';
import { orders, orderItems } from './schema';
const newOrder = await db.transaction(async (tx) => {
const [order] = await tx
.insert(orders)
.values({ userId: user.id })
.returning();
await tx.insert(orderItems).values([
{ orderId: order.id, productId: starterPlanId, quantity: 1 },
{ orderId: order.id, productId: proPlanId, quantity: 2 },
]);
return order;
});
This is more code. In a codebase with many such patterns, it adds up. The Prisma version is genuinely more concise for this use case, and the intent is clearer. You are not wrong to prefer Prisma if nested mutations are a frequent pattern in your application.
Who should switch, who should stay
Switch to Drizzle if: you deploy to serverless or edge runtimes where bundle size and cold start time affect your costs or your users; you write complex analytical queries that need real SQL; your team is comfortable with SQL and does not need the ORM to abstract it away; you want your migration files to be transparent SQL that any developer can read.
Stay with Prisma if: you are building a prototype or an internal tool where iteration speed matters more than runtime performance; your application creates and updates deeply nested relations frequently; your team includes developers who are less fluent in SQL and benefit from Prisma’s declarative style; you are on Prisma 7 and the bundle size is no longer a blocker for your deployment environment.
There is also a timing factor. Prisma 7 shipped in late 2025 and dropped the Rust engine entirely. As of this writing, the bundle is around 1.6MB, which is still over 200 times larger than Drizzle, but far less painful than the 14MB binary that made people switch in the first place. If your Prisma migration was purely about bundle size, reconsider whether the migration is still worth the cost.
For us, it was. The raw query typing and the removal of the generation step were worth it independently of the bundle wins. Six months in, I would make the same call again. But it is not the right choice for every team or every project, and anyone who tells you otherwise is selling something.
Where to go from here
If you want to try Drizzle on an existing project without a full migration, the Drizzle docs on drizzle-kit pull let you introspect an existing database and generate a TypeScript schema automatically. That gives you a starting point without rewriting your schema by hand.
The official Drizzle ORM documentation covers the relational query API, the sql operator, and the full set of migration commands. The Prisma comparison page on Prisma's own site is also worth reading: it is honest about the trade-offs from their perspective, which is more than most vendors manage.
메타데이터
- post_id
- 7b2bbcc0c19f
- slug
- i-replaced-prisma-with-drizzle-six-months-later-heres-what-i-gained-and-what-i-missed-7b2bbcc0c19f
- url
- https://medium.com/@sarathm09/i-replaced-prisma-with-drizzle-six-months-later-heres-what-i-gained-and-what-i-missed-7b2bbcc0c19f
- canonical_url
- https://medium.com/@sarathm09/i-replaced-prisma-with-drizzle-six-months-later-heres-what-i-gained-and-what-i-missed-7b2bbcc0c19f
- author_url
- https://medium.com/@sarathm09
- status
- ok
- fetched_at
- 2026-07-13 06:23:13