Prisma vs Drizzle vs Kysely: Best TypeScript ORM in 2026?
We built the same SaaS database layer in Prisma, Drizzle ORM, and Kysely. Full 2026 comparison of type safety, performance, migrations, bundle size, edge runtime support, and devel...
Why Your ORM Choice Matters More Than You Think
Your ORM is how your TypeScript code talks to your database — every data access pattern, every query, every migration flows through it. A poor ORM choice results in: type errors at runtime that TypeScript should have caught at compile time, slow queries because the ORM can't express efficient SQL, and blocked deployments because migrations are finicky or unsafe.
In 2026, the TypeScript ORM landscape has consolidated around three serious options. Prisma remains the most popular with the best DX for simple schemas. Drizzle has overtaken Prisma in growth rate with its SQL-first, edge-compatible approach — and is now the leading choice for new projects. Kysely is the type-safe query builder that gives SQL experts maximum control without abstraction overhead.
In 2024, Prisma was the clear default for new TypeScript projects. By mid-2026, Drizzle has surpassed Prisma in new project adoption for three reasons: native edge runtime support (Prisma still requires workarounds), smaller bundle size (critical for serverless cold starts), and SQL-first design that produces more predictable query plans. If you're starting a new project in 2026, Drizzle is the right default unless you have a specific reason to choose otherwise.
Prisma: Battle-Tested, Best DX, Biggest Ecosystem
Prisma's schema language is genuinely beautiful — the schema.prisma file is the clearest way to define a database schema in any ORM. The generated TypeScript client is safe and ergonomic. For teams moving fast on a straightforward CRUD SaaS, Prisma's DX advantage is real and significant.
The 2026 problem: Prisma's architecture was designed before edge runtimes were mainstream. The Prisma client uses a binary engine (Rust) that cannot run in Cloudflare Workers or Vercel Edge Functions without going through Prisma Accelerate — which adds latency and cost. For Node.js-only deployments, Prisma remains excellent. For edge-first apps, it's a significant limitation.
// prisma/schema.prisma — clean, readable schema definition
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env( "DATABASE_URL" )
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
plan String @default( "free" )
createdAt DateTime @default(now())
projects Project[]
members OrganizationMember[]
}
model Project {
id String @id @default(cuid())
name String
description String?
status String @default( "active" )
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([organizationId])
}
// lib/db.ts — singleton Prisma client
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient ()
if (process.env.NODE_ENV !== 'production' ) globalForPrisma.prisma = prisma
// Usage — ergonomic, deeply typed
const projects = await prisma.project. findMany ({
where: { organizationId: orgId, status: 'active' },
include: { organization: { select: { name: true , plan: true } } },
orderBy: { createdAt: 'desc' },
take: 50 ,
})Drizzle: SQL-First, Edge-Native, and Blazing Fast
Drizzle's design philosophy is "SQL-first TypeScript ORM" — it doesn't try to hide SQL from you, it makes writing SQL type-safe. The result: queries that are close to what PostgreSQL actually executes, excellent performance because there's no magic layer translating your ORM calls into suboptimal SQL, and compatibility with every JavaScript runtime including Cloudflare Workers.
The bundle size difference is decisive for edge and serverless deployments in 2026. Prisma's generated client is 5MB+. Drizzle's entire package is ~34KB. On Cloudflare Workers (which has a 1MB compressed worker size limit on the free plan), Prisma is literally incompatible without Prisma Accelerate.
// db/schema.ts — schema defined in TypeScript (no separate DSL)
import { pgTable, text, timestamp, uuid, index } from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'
export const organizations = pgTable ( 'organizations' , {
id: uuid ( 'id' ). primaryKey (). defaultRandom (),
name: text ( 'name' ). notNull (),
slug: text ( 'slug' ). unique (). notNull (),
plan: text ( 'plan' ). notNull (). default ( 'free' ),
createdAt: timestamp ( 'created_at' ). notNull (). defaultNow (),
})
export const projects = pgTable ( 'projects' , {
id: uuid ( 'id' ). primaryKey (). defaultRandom (),
name: text ( 'name' ). notNull (),
description: text ( 'description' ),
status: text ( 'status' ). notNull (). default ( 'active' ),
organizationId: uuid ( 'organization_id' ). notNull (),
createdAt: timestamp ( 'created_at' ). notNull (). defaultNow (),
}, (table) => ({
orgIdIdx: index ( 'projects_org_idx' ). on (table.organizationId),
}))
// Relations — for join helpers
export const organizationsRelations = relations (organizations, ({ many }) => ({
projects: many (projects),
}))
export const projectsRelations = relations (projects, ({ one }) => ({
organization: one (organizations, {
fields: [projects.organizationId],
references: [organizations.id],
}),
}))
// db/index.ts — Drizzle with postgres.js (works everywhere)
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
const sql = postgres (process.env.DATABASE_URL!, { max: 1 })
export const db = drizzle (sql, { schema })
// Usage — close to SQL but fully typed
import { eq, and, desc } from 'drizzle-orm'
const results = await db.query.projects. findMany ({
where: and (
eq (projects.organizationId, orgId),
eq (projects.status, 'active' )
),
with: { organization: { columns: { name: true , plan: true } } },
orderBy: desc (projects.createdAt),
limit: 50 ,
})Kysely: Type-Safe SQL for the Developers Who Love SQL
Kysely is not an ORM in the traditional sense — it's a type-safe query builder. You don't define a schema in Kysely; you define TypeScript types that represent your tables, and Kysely ensures your queries match those types. The SQL that Kysely generates is exactly what you'd write by hand — no surprises, no N+1 risks, no magic.
Kysely shines when you need queries that ORMs can't handle cleanly — window functions for analytics, CTEs for recursive data, LATERAL joins for correlated subqueries. If you find yourself constantly reaching for Prisma's $queryRaw or Drizzle's sql template literal, you probably want Kysely.
// db/types.ts — define your database types manually
import { Generated , Insertable , Selectable , Updateable } from 'kysely'
export interface OrganizationTable {
id: Generated < string >
name: string
slug: string
plan: 'free' | 'pro' | 'enterprise'
created_at: Generated < Date >
}
export interface ProjectTable {
id: Generated < string >
name: string
description: string | null
status: 'active' | 'archived'
organization_id: string
created_at: Generated < Date >
}
// Database interface — maps table names to their types
export interface Database {
organizations: OrganizationTable
projects: ProjectTable
}
// Kysely generates Insert/Select/Update types automatically:
export type Project = Selectable < ProjectTable >
export type NewProject = Insertable < ProjectTable >
export type UpdateProject = Updateable < ProjectTable >
// db/index.ts
import { Kysely , PostgresDialect } from 'kysely'
import { Pool } from 'pg'
import type { Database } from './types'
export const db = new Kysely < Database >({
dialect: new PostgresDialect ({ pool: new Pool ({ connectionString: process.env.DATABASE_URL }) })
})
// Queries — SQL-shaped, fully typed
const projects = await db
. selectFrom ( 'projects as p' )
. innerJoin ( 'organizations as o' , 'o.id' , 'p.organization_id' )
. select ([ 'p.id' , 'p.name' , 'p.status' , 'o.name as orgName' ])
. where ( 'p.organization_id' , '=' , orgId)
. where ( 'p.status' , '=' , 'active' )
. orderBy ( 'p.created_at' , 'desc' )
. limit ( 50 )
. execute ()
// Complex query — window function (Prisma can't do this without $queryRaw)
const projectsWithRank = await db
. selectFrom ( 'projects' )
. select ([
'id' , 'name' ,
db. raw ( 'ROW_NUMBER() OVER (PARTITION BY organization_id ORDER BY created_at) as row_num' )
. as ( 'position' )
])
. execute ()Same Query, Three Ways
The most revealing comparison: the same complex query — projects with member count, recent activity, and org plan — written in Prisma, Drizzle, and Kysely. Watch how each handles joins, aggregations, and computed fields.
// Prisma: readable but requires groupBy workaround for aggregations
// Count requires _count — aggregations feel like an afterthought
const projects = await prisma.project. findMany ({
where: { organizationId: orgId, status: 'active' },
include: {
organization: {
select: { name: true , plan: true }
},
_count: {
select: { members: true } // Aggregation via _count
}
},
orderBy: { createdAt: 'desc' },
take: 50 ,
})
// For custom aggregations (e.g., avg task completion),
// Prisma forces you to use $queryRaw — breaks type safety
const statsRaw = await prisma. $queryRaw `
SELECT project_id, AVG(completion_rate) as avg_rate
FROM tasks WHERE organization_id = ${orgId}
GROUP BY project_id
`
// ⚠ statsRaw is typed as `unknown` — no safety// Drizzle: SQL-close means aggregations feel natural
// count() is a first-class function, fully typed
import { eq, and, desc, count , avg } from 'drizzle-orm'
const projects = await db
.select({
id: projects.id,
name: projects.name,
status: projects.status,
orgName: organizations.name,
orgPlan: organizations.plan,
memberCount: count (members.id), // ✅ Typed aggregation
})
.from(projects)
.leftJoin(organizations, eq (projects.organizationId, organizations.id))
.leftJoin(members, eq (members.projectId, projects.id))
.where( and (
eq (projects.organizationId, orgId),
eq (projects.status, 'active' )
))
.groupBy(projects.id, organizations.id)
.orderBy( desc (projects.createdAt))
.limit( 50 )
// Custom aggregation — still typed, no raw SQL needed
const stats = await db
.select({
projectId: tasks.projectId,
avgCompletion: avg (tasks.completionRate), // ✅ Returns number | null
})
.from(tasks)
.where( eq (tasks.organizationId, orgId))
.groupBy(tasks.projectId)// Kysely: most SQL-like, most control, most verbose
// But the query you write is exactly what PostgreSQL runs
import { sql } from 'kysely'
const projects = await db
. selectFrom ( 'projects as p' )
. innerJoin ( 'organizations as o' , 'o.id' , 'p.organization_id' )
. leftJoin ( 'project_members as pm' , 'pm.project_id' , 'p.id' )
. select ([
'p.id' ,
'p.name' ,
'p.status' ,
'o.name as orgName' ,
'o.plan as orgPlan' ,
db. fn . count ( 'pm.id' ). as ( 'memberCount' ), // ✅ Typed
])
. where ( 'p.organization_id' , '=' , orgId)
. where ( 'p.status' , '=' , 'active' )
. groupBy ([ 'p.id' , 'o.id' ])
. orderBy ( 'p.created_at' , 'desc' )
. limit ( 50 )
. execute ()
// Window function — Kysely's strongest differentiator
const ranked = await db
. selectFrom ( 'projects' )
. select ([
'id' , 'name' , 'organization_id' ,
sql <number> `ROW_NUMBER() OVER (
PARTITION BY organization_id
ORDER BY created_at DESC
)` . as ( 'rank' )
])
. execute ()Every Dimension Compared
Migrations: How Each Handles Schema Changes
Migrations are where you feel the ORM choice every day. Every feature you add requires a migration. Every column you rename or add requires it to be safe in production. Here's how each handles it.
── PRISMA MIGRATIONS ────────────────────────────────
# 1. Edit schema.prisma
# 2. Generate migration file
npx prisma migrate dev --name add_project_description
# Prisma creates: migrations/20260512_add_project_description/migration.sql
# Automatically applies in dev, tracks history in _prisma_migrations table
# Deploy to production:
npx prisma migrate deploy
# Applies any pending migrations — safe, idempotent
── DRIZZLE MIGRATIONS ───────────────────────────────
# 1. Edit db/schema.ts
# 2. Generate migration SQL by diffing schema
npx drizzle-kit generate
# Creates: drizzle/0003_add_project_description.sql
# You review the SQL before applying — more control than Prisma
# Apply migration:
npx drizzle-kit migrate
# Or use drizzle-orm/migrator in code for programmatic apply
# Push schema directly in dev (no migration files):
npx drizzle-kit push
# ⚠ Dev only — skips migration files, syncs schema directly
── KYSELY MIGRATIONS ────────────────────────────────
# Kysely doesn't include migration tooling
# Best option: Kysely migrations with manual SQL files
import { Migrator , FileMigrationProvider } from 'kysely'
import * as path from 'path'
const migrator = new Migrator ({
db,
provider: new FileMigrationProvider ({
fs, path,
migrationFolder: path. join (__dirname, 'migrations' )
})
})
await migrator. migrateToLatest ()
# Or use a separate tool: dbmate, flyway, golang-migrateIf you're starting a new Next.js SaaS in 2026 and deploying to Vercel or Cloudflare, use Drizzle. If you're on Node.js only and your team values the cleanest ORM DX, Prisma is still excellent. If you love SQL and want zero magic, Kysely is your tool.
When to Use Each ORM
The 2026 Migration-Safe Recommendation
Start new projects with Drizzle. If you're on Prisma and deploying to Node.js-only (not edge), stay on Prisma until you have a specific pain point — migrations are expensive. If you hit edge runtime requirements, bundle size issues, or slow query performance — migrate to Drizzle. The Kysely niche is genuinely complex analytics SQL; if you're writing CTEs and window functions regularly, Kysely's zero-magic approach is worth the verbosity.
What to Read Next
- Multi-Tenant SaaS Architecture 2026: Next.js, Supabase RLS & Clerk →
- Supabase vs Firebase vs Pocketbase: Which Backend? →
- Cloudflare Workers, Pages, D1 & R2: Edge-First Development →
- tRPC vs REST vs GraphQL: TypeScript API Layer Comparison →
