Prisma vs Drizzle vs TypeORM vs Kysely: Type Safety, Performance and Migrations Compared
Key takeaways
Prisma, Drizzle, TypeORM, and Kysely each take a different approach to database access in TypeScript. This comparison covers type safety, query ergonomics, performance, bundle size, and migration workflows to help you pick the right tool.
The Four Options
| Prisma | Drizzle | TypeORM | Kysely | |
|---|---|---|---|---|
| Type safety | Excellent | Excellent | Good | Excellent |
| Query style | Object API | SQL-like | Active Record | SQL builder |
| Bundle size | Large with the Rust engine; smaller with the Rust-free client | Tiny (~50KB) | Medium | Tiny (~20KB) |
| Edge/serverless | Via driver adapters | Native | No | Yes |
| Migrations | Schema → Migration | Code-first | Decorators | Migrator (TS files) |
| Raw SQL | Supported | Natural | Supported | Core |
| Learning curve | Low | Medium | Medium | Low-Medium |
| Ecosystem | Large | Growing | Mature | Small |
The table hides the question that usually decides the choice: where does the source of truth for your schema live? Prisma keeps it in a separate .prisma file and generates TypeScript from it. Drizzle keeps it in TypeScript and infers types directly from table definitions. TypeORM keeps it in decorated classes and reads metadata at runtime. Kysely does not own the schema at all — it only needs a TypeScript interface describing tables that already exist, which you write or generate from the live database. Everything else (migration workflow, how raw SQL feels, what breaks in edge runtimes) follows from that design decision, so it is worth deciding first which side you want to be authoritative: the ORM or the database.
Prisma
Prisma uses a schema file to generate types and a query client.
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
}
# Generate client + migrate
npx prisma migrate dev --name add-users
npx prisma generate
migrate dev diffs schema.prisma against the migration history, writes a new SQL file under prisma/migrations/, and applies it to the development database. In production you run prisma migrate deploy, which only applies pending files and never generates anything — mixing the two up (running migrate dev against a shared database) is how people end up being asked to reset a database they cannot reset. prisma generate rebuilds the typed client from the schema; if you pull a teammate’s schema change and forget it, TypeScript will report that properties such as db.user.someNewField do not exist even though the database has the column.
import { PrismaClient } from '@prisma/client'
const db = new PrismaClient()
// Fully typed queries
const users = await db.user.findMany({
where: { email: { contains: '@example.com' } },
include: { posts: { where: { published: true } } },
orderBy: { createdAt: 'desc' },
take: 10,
})
// users: (User & { posts: Post[] })[]
// Create with relations
const user = await db.user.create({
data: {
email: '[email protected]',
name: 'Alice',
posts: {
create: [
{ title: 'First Post', content: 'Hello world', published: true }
]
}
},
include: { posts: true }
})
// Transaction
await db.$transaction(async (tx) => {
const user = await tx.user.create({ data: { email: '[email protected]', name: 'Bob' } })
await tx.post.create({ data: { title: 'Welcome', content: '...', authorId: user.id } })
})
The return type of findMany is computed from the arguments: add include: { posts: true } and the result type gains posts; add select: { email: true } and everything except email disappears from the type. This is the feature that makes Prisma feel safe — you cannot accidentally read a relation you did not load. The cost is that Prisma decides how to fetch it. For a long time an include meant a second query (SELECT ... FROM "Post" WHERE "authorId" IN (...)) rather than a SQL join; newer versions can use a join strategy (relationLoadStrategy: 'join'), but you should still turn on query logging (new PrismaClient({ log: ['query'] })) before assuming what hits the database.
Prisma strengths:
- Best-in-class DX — intuitive, auto-complete everywhere
- Schema → types → client in one flow
- Prisma Studio (visual DB browser)
- Strong documentation
Prisma weaknesses:
- Historically shipped a native Rust query engine (tens of MB); Prisma 6.16+ offers a Rust-free TypeScript client, which became the default in Prisma 7
- Edge runtimes such as Cloudflare Workers need a driver adapter (or Prisma Accelerate) rather than a direct TCP connection
- Complex raw SQL integration (
$queryRawresults are typed only as what you annotate, or via TypedSQL) - Slower cold starts in serverless, especially with the Rust engine
The most common Prisma problem I see in real projects is not performance but connection count. Every new PrismaClient() opens its own pool, and in Next.js development mode each hot reload re-evaluates the module, so a naive const db = new PrismaClient() leaks clients until Prisma prints warn(prisma-client) There are already 10 instances of Prisma Client actively running and PostgreSQL eventually refuses connections with sorry, too many clients already. The fix is to cache the client on globalThis in development and, in serverless production, to put a pooler (PgBouncer, Supabase/Neon pooling, or Accelerate) in front of the database because every concurrent function instance holds its own pool.
Drizzle ORM
Drizzle defines schema in TypeScript — no separate schema file.
// db/schema.ts
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core'
import { relations, sql } from 'drizzle-orm'
export const users = pgTable('users', {
id: text('id').primaryKey().default(sql`gen_random_uuid()`),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const posts = pgTable('posts', {
id: text('id').primaryKey().default(sql`gen_random_uuid()`),
title: text('title').notNull(),
content: text('content').notNull(),
published: boolean('published').default(false).notNull(),
authorId: text('author_id').notNull().references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}))
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}))
Two different things are declared here, and confusing them is a classic Drizzle mistake. .references(() => users.id) creates a real foreign key constraint in the generated migration. The relations() calls create nothing in the database — they only tell Drizzle’s relational query API how tables connect, so that db.query.users.findMany({ with: { posts: true } }) knows which columns to join on. You can have a foreign key without a relations() entry (then with won’t work) or a relation without a foreign key (then the database won’t enforce integrity). Newer Drizzle releases are moving relation definitions to a single defineRelations call, but the split between constraint and query metadata is the same.
import { drizzle } from 'drizzle-orm/node-postgres'
import { eq, like, desc } from 'drizzle-orm'
import * as schema from './schema'
const db = drizzle(pool, { schema })
// SQL-like queries
const users = await db
.select()
.from(schema.users)
.where(like(schema.users.email, '%@example.com'))
.orderBy(desc(schema.users.createdAt))
.limit(10)
// Relational query (auto-joins)
const usersWithPosts = await db.query.users.findMany({
with: {
posts: { where: (posts, { eq }) => eq(posts.published, true) }
},
limit: 10,
})
// Insert
const [user] = await db.insert(schema.users)
.values({ email: '[email protected]', name: 'Alice' })
.returning()
// Transaction
await db.transaction(async (tx) => {
const [user] = await tx.insert(schema.users)
.values({ email: '[email protected]', name: 'Bob' })
.returning()
await tx.insert(schema.posts)
.values({ title: 'Welcome', content: '...', authorId: user.id })
})
The query code shows Drizzle’s two faces. The select().from().where() chain maps almost one-to-one onto SQL and returns flat rows; joins return objects keyed by table name, and you shape nested results yourself. The db.query.* API returns nested objects like Prisma does, but it only exists if you passed { schema } to drizzle() — without it, db.query.users is undefined and you get a runtime TypeError: Cannot read properties of undefined (reading 'findMany'). Also note that .returning() is a PostgreSQL/SQLite feature; on MySQL you get an insert result with insertId instead, so code written against Postgres does not always port unchanged.
Drizzle strengths:
- Tiny bundle — works in Cloudflare Workers, Deno, Bun
- SQL-like API — easy to understand what query is generated
- Fast — minimal abstraction overhead
- TypeScript-first schema
Drizzle weaknesses:
- Less beginner-friendly than Prisma
- Smaller ecosystem/community
- Drizzle Studio is younger and less polished than Prisma Studio
TypeORM
TypeORM uses decorators on classes.
// entity/User.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn } from 'typeorm'
import { Post } from './Post'
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string
@Column({ unique: true })
email: string
@Column()
name: string
@OneToMany(() => Post, post => post.author)
posts: Post[]
@CreateDateColumn()
createdAt: Date
}
// entity/Post.ts
@Entity('posts')
export class Post {
@PrimaryGeneratedColumn('uuid')
id: string
@Column()
title: string
@Column('text')
content: string
@Column({ default: false })
published: boolean
@ManyToOne(() => User, user => user.posts)
author: User
@Column()
authorId: string
}
import { DataSource, Like } from 'typeorm'
const dataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [User, Post],
synchronize: false, // use migrations in production!
logging: false,
})
await dataSource.initialize()
const userRepo = dataSource.getRepository(User)
// Find with relations
const users = await userRepo.find({
where: { email: Like('%@example.com') },
relations: { posts: true },
order: { createdAt: 'DESC' },
take: 10,
})
// Query builder (more complex queries)
const result = await userRepo.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.where('post.published = :published', { published: true })
.orderBy('user.createdAt', 'DESC')
.getMany()
TypeORM strengths:
- Mature, battle-tested
- Active Record + Data Mapper patterns
- Works with many databases
TypeORM weaknesses:
- Weaker TypeScript inference than Prisma/Drizzle
- Decorator API can be complex
- Type inference for relations is unreliable
- Not recommended for new projects in 2026
“Weaker inference” has a concrete meaning. userRepo.find() returns User[], and User.posts is declared as Post[] — whether or not you asked for relations: { posts: true }. Forget the relation option and the compiler is happy while user.posts is undefined at runtime. The query builder is looser still: 'post.published = :published' is a string, so a typo in a column name surfaces as a database error (column post.publised does not exist), not a type error. Setup also has sharp edges: decorators require experimentalDecorators and emitDecoratorMetadata in tsconfig.json plus an import 'reflect-metadata' before anything else, and bundlers that strip decorator metadata (esbuild does not emit it) produce errors such as DataTypeNotSupportedError or ColumnTypeUndefinedError that look like schema problems.
The setting to be most careful with is synchronize: true. It makes TypeORM alter tables at startup to match your entities, which is convenient locally but dangerous anywhere with real data: renaming a property is seen as “drop column, add column”, and the old data is gone. Keep it false outside throwaway databases and generate migrations instead.
Kysely
Kysely is a type-safe SQL query builder — no schema file, SQL-first.
import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
// Define database types manually
interface Database {
users: {
id: string
email: string
name: string
created_at: Date
}
posts: {
id: string
title: string
content: string
published: boolean
author_id: string
created_at: Date
}
}
const db = new Kysely<Database>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL }) }),
})
// Fully typed SQL queries
const users = await db
.selectFrom('users')
.select(['id', 'email', 'name'])
.where('email', 'like', '%@example.com')
.orderBy('created_at', 'desc')
.limit(10)
.execute()
// Join
const usersWithPosts = await db
.selectFrom('users')
.innerJoin('posts', 'posts.author_id', 'users.id')
.select([
'users.id',
'users.name',
'posts.title',
'posts.published',
])
.where('posts.published', '=', true)
.execute()
// Insert
const user = await db
.insertInto('users')
.values({ id: randomUUID(), email: '[email protected]', name: 'Alice', created_at: new Date() })
.returning(['id', 'email'])
.executeTakeFirstOrThrow()
// Transaction
await db.transaction().execute(async (trx) => {
const user = await trx.insertInto('users')
.values({ /* ... */ })
.returningAll()
.executeTakeFirstOrThrow()
await trx.insertInto('posts')
.values({ author_id: user.id, /* ... */ })
.execute()
})
Kysely strengths:
- Complete SQL control — no magic
- Excellent TypeScript inference
- Tiny bundle — edge/serverless friendly
- SQL is what you get — no surprises
Kysely weaknesses:
- Must define types manually (or use codegen)
- Migrations are minimal: a
Migratorrunsup/downfunctions you write, with no schema diffing - More verbose than Prisma for simple queries
- Less beginner-friendly
The hand-written Database interface above is the weakest part of the example, and it shows up in the insert: because id and created_at are typed as plain string and Date, Kysely demands them in every .values({...}) even though the database has defaults. Kysely’s own types fix this — declare id: Generated<string> and created_at: ColumnType<Date, Date | undefined, never> and inserts may omit them while selects still return them. In practice most teams generate the interface with kysely-codegen from the live database (or prisma-kysely from a Prisma schema), so the types can’t drift from the real tables. That is also the answer to “what about migrations?”: Kysely assumes the database is the source of truth, and its Migrator simply runs versioned TypeScript files in order.
Kysely’s inference is surprisingly deep: in the join query, 'posts.title' is only accepted because posts was joined, and the result type contains exactly id, name, title, and published. Rename a column in the interface and every query that referenced it fails to compile, which is the main reason to prefer it over raw pg queries.
Decision Guide
New project, team of developers, CRUD-heavy app?
→ Prisma (best DX, docs, onboarding)
Edge/serverless (Cloudflare Workers, Deno Deploy)?
→ Drizzle (tiny bundle, native edge support)
Need fine-grained SQL control, complex queries?
→ Drizzle or Kysely
Existing TypeORM project?
→ Keep TypeORM (migration cost not worth it unless you have issues)
Library or tool needing zero dependencies?
→ Kysely (SQL-first, typed, tiny)
Performance Comparison
The figures below are illustrative orders of magnitude for a PostgreSQL table with around 10K rows under moderate concurrency, not a reproducible benchmark:
| Operation | Prisma | Drizzle | TypeORM | Kysely | Raw SQL |
|---|---|---|---|---|---|
| Simple SELECT | 3.2ms | 1.1ms | 2.8ms | 1.0ms | 0.8ms |
| JOIN query | 5.1ms | 1.8ms | 4.2ms | 1.6ms | 1.2ms |
| INSERT + return | 2.8ms | 1.2ms | 2.5ms | 1.1ms | 0.9ms |
| Cold start | +80ms | +5ms | +40ms | +3ms | — |
Approximate values. Real-world differences depend on query complexity and infrastructure.
Where does the overhead actually come from? Drizzle and Kysely build a SQL string and hand it to the driver, so their cost is close to the driver’s own. Prisma translates its object query into SQL through a query engine and then maps rows back into nested objects; TypeORM hydrates entity instances and resolves metadata. Those steps are measured in fractions of a millisecond to a few milliseconds, while a missing index or an N+1 relation load costs tens or hundreds. In other words, per-query ORM overhead almost never decides whether an API is fast — the SQL it produces does. Cold start is the exception: in serverless functions that start frequently, the time to load and initialize the client is paid on every cold invocation, which is why the lightweight builders are popular on edge runtimes.
Migration Strategy
| Tool | Approach |
|---|---|
| Prisma | Edit schema.prisma → prisma migrate dev |
| Drizzle | Edit schema files → drizzle-kit generate → drizzle-kit migrate |
| TypeORM | Edit entities → typeorm migration:generate |
| Kysely | Write up/down migration files and run them with Migrator |
Every generator in this table works by diffing, and diffs cannot see intent. Rename name to fullName in a Prisma schema and migrate dev produces DROP COLUMN "name" plus ADD COLUMN "fullName" — the data is lost unless you edit the generated SQL into a RENAME COLUMN before applying it. drizzle-kit generate asks interactively whether a column was renamed or created, which is better, but it also means the command cannot run unattended in CI. TypeORM’s migration:generate compares entities to the current database, so running it against a database that is not at the latest migration yields a migration full of unrelated changes. Whichever tool you pick, treat generated migrations as drafts: read the SQL, commit it, and apply the same files to every environment.
Frequently Asked Questions (FAQ)
Q. What should I check before moving an existing project from TypeORM to Prisma or Drizzle?
A. Start from the database, not the entity classes: both Prisma and Drizzle can introspect an existing schema, so you can generate models from the current tables instead of rewriting them by hand. Then check the behaviors TypeORM gave you implicitly, such as entity listeners, cascades, and lazy relations, because they have no one-to-one equivalent and need explicit code. Migrating one module or repository at a time against the same database is usually safer than a big-bang switch.
Related Articles
- Drizzle ORM Basics
- Prisma ORM in TypeScript
- PostgreSQL vs MySQL: Data Types, JSON, Locking, Replication and When to Use Each