Tools27

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...

Prisma vs Drizzle vs Kysely: Best TypeScript ORM in 2026?

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-migrate

If 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.

  • 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 →