Why We Chose Drizzle ORM Over Prisma for Production
Why We Chose Drizzle ORM Over Prisma for Production
Every TypeScript team building on PostgreSQL eventually faces this decision: Prisma or Drizzle? We started HyrecruitAI with Prisma, ran it in production for four months, then migrated to Drizzle. Here is why.
The Prisma Experience
Prisma is genuinely good for getting started. The schema DSL is clean, prisma migrate is straightforward, and the generated client gives you solid autocompletion. For our first three months, it worked fine.
The friction started when our queries got complex. We needed multi-table joins with conditional filters, aggregations for analytics dashboards, and CTEs for our reporting pipeline. Prisma's query API kept pushing us toward multiple round trips or raw SQL escape hatches.
// Prisma: Two queries where one would do
const interviews = await prisma.interview.findMany({
where: { companyId },
include: { evaluations: true },
});
// Then filter/aggregate in JavaScript
const stats = interviews.map(i => computeStats(i.evaluations));
The real breaking point was a reporting query. We needed a CTE to compute rolling 30-day interview completion rates per company, then join that against the companies table with conditional aggregation. Prisma cannot express CTEs. The only option was $queryRaw, which throws away all type safety:
// Prisma: raw SQL escape hatch, zero type safety
const report = await prisma.$queryRaw`
WITH monthly_stats AS (
SELECT company_id, COUNT(*) as completed
FROM interviews
WHERE status = 'completed'
AND completed_at > NOW() - INTERVAL '30 days'
GROUP BY company_id
)
SELECT c.name, COALESCE(ms.completed, 0) as completed_30d
FROM companies c
LEFT JOIN monthly_stats ms ON ms.company_id = c.id
`;
// report is `unknown` — you are on your own for types
The other pain point was the generated client size. Prisma generates a Node.js query engine binary that added ~15MB to our deployment artifact. In a serverless context, that matters.
Why Drizzle Won
Drizzle takes a fundamentally different approach. Instead of generating a client from a schema file, you define your schema in TypeScript and get type-safe queries that map directly to SQL.
Type Safety Without Code Generation
Drizzle schemas are just TypeScript:
export const interviews = pgTable('interviews', {
id: uuid('id').primaryKey().defaultRandom(),
companyId: uuid('company_id').notNull().references(() => companies.id),
candidateName: text('candidate_name').notNull(),
status: text('status', { enum: ['scheduled', 'in_progress', 'completed'] }).notNull(),
scheduledAt: timestamp('scheduled_at').notNull(),
createdAt: timestamp('created_at').defaultNow(),
});
No prisma generate step. No watching for schema changes. The types flow directly from the table definitions to your queries. When I change a column, TypeScript catches every broken reference at compile time.
Queries That Think in SQL
The biggest win is the relational query builder. Complex joins, subqueries, and aggregations feel natural because Drizzle does not try to hide SQL -- it wraps it in type safety:
const results = await db
.select({
companyName: companies.name,
totalInterviews: count(interviews.id),
avgScore: avg(evaluations.overallScore),
})
.from(companies)
.leftJoin(interviews, eq(interviews.companyId, companies.id))
.leftJoin(evaluations, eq(evaluations.interviewId, interviews.id))
.groupBy(companies.id)
.having(gt(count(interviews.id), 0));
That is one query, fully typed, readable, and it generates exactly the SQL you would write by hand.
Migration Workflow
Drizzle Kit handles migrations with a push command for development and generate plus migrate for production:
# Development: push schema changes directly
bunx drizzle-kit push
# Production: generate migration SQL, review it, apply it
bunx drizzle-kit generate
bunx drizzle-kit migrate
We review every generated migration file before it runs in CI. The SQL is readable and predictable -- no surprises.
Performance Comparison
We benchmarked both ORMs against our production query patterns:
- Simple CRUD: Negligible difference. Both are fast enough.
- Complex joins (3+ tables): Drizzle generated single queries where Prisma often produced N+1 patterns. 40-60% fewer database round trips.
- Cold start time: Drizzle added ~50ms to cold starts vs Prisma's ~300ms (due to the query engine initialization).
- Bundle size: Drizzle added ~500KB to our deployment. Prisma added ~15MB.
The Migration
Moving from Prisma to Drizzle took us about two weeks for a codebase with ~40 database models. The approach:
- Wrote Drizzle schemas that matched our existing Prisma schema exactly
- Ran both ORMs in parallel for a week (Drizzle for reads, Prisma for writes)
- Migrated writes to Drizzle module by module
- Removed Prisma entirely
The hardest part was the details nobody warns you about:
- Exact schema matching. The Drizzle schema had to match the existing database exactly, including default values and constraints that Prisma managed implicitly. Prisma auto-generates
createdAtdefaults and@updatedAttriggers. In Drizzle, you define these explicitly:.defaultNow()and application-level update timestamps. - Enum conversion. Prisma uses its own enum representation. Drizzle maps to PostgreSQL native enums. We wrote a migration that converted Prisma's enum columns to native PostgreSQL enums without data loss —
ALTER TABLE ... ALTER COLUMN ... TYPE ... USING .... - Seed scripts. Prisma's
createManyhas no direct Drizzle equivalent. We wrote a batch insert utility usingdb.insert(table).values([...])with chunking for large datasets. - Prepared statements and PgBouncer. We run PgBouncer in transaction mode for connection pooling. One gotcha: Drizzle's prepared statements do not work with PgBouncer's transaction mode because prepared statements are scoped to a connection, and PgBouncer rotates connections between transactions. We disable prepared statements in the Drizzle connection config with
prepare: false.
The actual query migration was mechanical — Drizzle's API is intuitive enough that most conversions were one-to-one.
When Prisma Still Makes Sense
I would still recommend Prisma for teams that want a batteries-included ORM with excellent documentation and a gentler learning curve. If your queries are mostly simple CRUD, the differences are marginal.
But if you are building something with complex data access patterns, care about bundle size, or just prefer staying close to SQL while keeping type safety -- Drizzle is the better tool. For HyrecruitAI, it was the right call.
For how we optimize the queries Drizzle generates in production, see PostgreSQL Performance Patterns We Use at HyrecruitAI.