Migrating from Prisma to Drizzle: A Six-Month Retrospective
Six months after moving a PostgreSQL TypeScript stack from Prisma to Drizzle ORM: real benchmarks, what got better right away, and the trade-offs that hurt.
This article is published in English.
Sources: I replaced Prisma with Drizzle. Six months later, here’s what I gained and what I missed.
About eight months ago, a nagging doubt about Prisma finally crystallized into something worth acting on. It wasn't that anything was outright broken—the friction had simply built up slowly over time until it became impossible to ignore. The setup involved several small applications, all running on PostgreSQL, written in TypeScript, sitting behind a handful of API services. Nothing unusual or cutting-edge. Prisma was doing what it was supposed to do. Still, a few recurring annoyances kept surfacing.
The generated @prisma/client was huge. When running on serverless functions, cold starts dragged noticeably because the client had to be loaded first. The prisma generate step in the CI pipeline started to feel like a toll paid on every single merge. And any time a raw query was needed for something more complex than the ORM could express, the return type came back as unknown[], offering zero type safety and forcing manual casts everywhere.
Eventually, the decision was made to move to Drizzle ORM, with the migration wrapped up roughly six months ago. What follows is a candid look at how that went.
Why I looked at Drizzle in the first place
The bundle size issue was the first real trigger for taking Drizzle seriously. At the point of migration, the Prisma client alone was adding close to 14MB to the Lambda deployment package, with the Rust query engine binary accounting for several of those megabytes on its own. Endpoints that weren't hit frequently saw cold starts of 2 to 3 seconds—the kind of number that stands out immediately on a latency dashboard.
Drizzle, by comparison, comes in at around 7.4KB minified and gzipped, with no runtime dependencies at all. No compiled binary, no separate engine process, no serialization step sitting between your TypeScript code and the database connection.
The second source of friction was the code generation step. Any schema change meant running prisma generate before TypeScript would recognize the new shape. In practice that led to one of two bad outcomes: either you ran it manually and occasionally forgot to commit the regenerated client, or you wired it into every CI run and absorbed an extra 15 to 20 seconds per build. Neither option was satisfying.
With Drizzle, the schema itself is just TypeScript. Edit the schema file and the types update immediately—there's no generation step in between, and the compiler picks up the change right away.
The third motivator was how raw SQL was handled. Prisma treats it purely as a fallback: calling $queryRaw hands you back unknown[], which then needs either a manual cast or a Zod schema to make it safe to use. Drizzle's sql template tag, on the other hand, lets you declare the shape directly—something like sql<{ id: number; total: number }>—and you get exactly that type back, with no casting and no extra validation layer required.
What the migration looked like
To illustrate, consider three tables: users, products, and orders. Under Prisma, they were defined in a single 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
}
Under Drizzle, the same schema becomes 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] }),
}));
Yes, it runs a bit longer. But it's TypeScript from top to bottom. Your editor understands every field on the schema, autocomplete works directly against it, and you can import pieces of the schema straight into your application logic.
A drizzle.config.ts file pulls 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. Skip it, and Drizzle may interpret a column rename as "drop the old column, create a new one"—a change that silently destroys data until you dig into the generated SQL to see what happened. Turning on strict: true makes Drizzle pause and ask for confirmation whenever a change is ambiguous.
With the schema in place, generating a migration is a single command:
bunx drizzle-kit generate
That produces a SQL file inside ./drizzle/migrations. You inspect it, confirm it does what you expect, and then apply it:
bunx drizzle-kit migrate
That SQL file gets checked into version control right next to the schema. The entire migration history is plain, readable SQL rather than a log format that only makes sense through Prisma's own tooling.
What got better immediately
Smaller bundles, faster cold starts. This was obvious right away and easy to measure. Our Lambda bundle sizes fell by more than 90 percent. Functions that previously needed over two seconds to cold start now came in under 400ms. Background jobs saw no real benefit from this, but our API functions noticeably sped up.
Typed raw SQL. This became useful on literally the second day of the migration. We had a reporting query joining four tables with aggregations that the Prisma query builder simply could not express in a clean way. The Prisma fallback for this kind of query 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
`;
With Drizzle, the equivalent query comes back with an actual type attached:
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 write the interface by hand instead of getting it inferred automatically, but that is a minor cost compared to sprinkling unknown casts throughout the codebase.
Drizzle Studio. Running bunx drizzle-kit studio spins up a local database browser. It is rougher around the edges than Prisma Studio, but it is quick, functional, and doesn't need a Prisma account or any connection to the cloud. For day-to-day local work, that independence is worth something.
What took adjustment
Relations require more explicit wiring. In Prisma, fetching an order along with its user and line items was as simple as nesting an include:
// Prisma - nested include
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
user: true,
items: {
include: { product: true }
}
}
});
Drizzle's relational query API gets you to a similar result:
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 final shape of the two queries is close. The catch is that the with syntax only works once you've defined the relations() calls in your schema, the same ones described earlier. When we first switched over, we hadn't finished setting those up, and we lost an afternoon puzzling over why with wasn't returning anything. Once the relations are properly declared, everything works as expected—but that initial setup step is something Prisma handles for you implicitly from the schema alone, with nothing extra to configure.
Seeding is now a manual job. Prisma ships with a seeding convention built in: drop a seed.ts file in place, run prisma db seed, and you're done. Drizzle has no equivalent built-in command. Instead, you write an ordinary TypeScript script and execute 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);
And you wire it up in package.json:
{
"scripts": {
"db:seed": "bun run src/seed.ts"
}
}
None of this is difficult, and arguably it's more transparent than Prisma's implicit convention. But any existing tooling or CI steps that called prisma db seed directly will need to be rewritten to call your own script instead.
Comparing the two studios directly. Drizzle Studio does the job. Prisma Studio simply looks nicer and offers more: built-in filter controls, pagination, and a more polished table editor. Drizzle Studio, by comparison, feels bare-bones. Six months into using Drizzle, we've ended up relying on both tools less than we expected, since most real data exploration happens through a dedicated database client regardless of which ORM you're using.
The one case where Prisma still wins
Nested writes. When you need to insert an order together with its line items in one step, Prisma handles it cleanly:
// 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 has no equivalent for nested creates. Instead, you write the logic yourself inside 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;
});
That's noticeably more code to write and maintain. If your application relies heavily on this kind of pattern, the extra boilerplate adds up fast. Prisma's version stays shorter and communicates intent more directly. If nested mutations show up often in your domain model, sticking with Prisma is a completely reasonable choice.
Deciding whether to make the move
Consider moving to Drizzle if any of these apply: you're deploying on serverless or edge platforms where bundle size and cold-start latency have a direct impact on cost or user experience; you frequently write nontrivial analytical SQL and want direct access to it; your team is fluent enough in SQL that an abstraction layer isn't adding much value; or you want migrations expressed as plain SQL files that any engineer, regardless of ORM familiarity, can read and review.
Stick with Prisma if: you're working on a prototype or an internal tool where shipping fast matters more than shaving milliseconds off runtime; your app does a lot of deep nested-relation writes and reads; part of your team is less comfortable with raw SQL and benefits from Prisma's declarative modeling style; or you've already moved to Prisma 7, where the historical bundle-size problem is far less severe.
Timing matters here too. Prisma 7, released in late 2025, eliminated the Rust engine entirely. Its current bundle sits around 1.6MB — still more than 200 times heavier than Drizzle's footprint, but nowhere near the 14MB binary that originally pushed people toward alternatives. If bundle size was the only reason you were considering a switch, it's worth checking whether that reason still holds given how much Prisma has slimmed down.
For the team behind this migration, the answer was still yes. Better raw-query typings and no code-generation step justified the switch on their own, independent of any size savings. Looking back after six months, the decision holds up. That said, it isn't the universally correct answer — anyone claiming one ORM is objectively superior for every use case isn't being straight with you.
Next steps if you want to explore this yourself
If you'd like to experiment with Drizzle against a database you already have, without committing to a full rewrite, the Drizzle Kit introspection tooling lets drizzle-kit pull read your existing schema and generate the corresponding TypeScript definitions automatically. That gives you a working baseline instead of hand-writing every table definition from scratch.
The official Drizzle ORM documentation walks through the relational query builder, the sql template operator, and the complete migration command set. It's also worth reading Prisma's own comparison page against Drizzle — it's a fair account of the trade-offs from their side, which is more candor than most vendor comparisons offer.