Where the schema lives: schema-first ORM to SQL-first query builder
The three tools differ most in where the authoritative description of your tables lives. That decision determines how types are produced, what a migration is, and who owns the database's shape.
Prisma is a schema-first ORM. You describe models in a schema.prisma file using Prisma's own schema language. A generator reads it and emits a typed client, and Prisma Migrate diffs it to produce SQL migrations. TypeScript never defines the schema; it consumes generated output.
Drizzle moves the source of truth into TypeScript. Tables are declared with functions such as pgTable, so the schema is ordinary code you can import, compose and refactor. Types are inferred directly from those definitions, with no generation step. drizzle-kit compares the definitions against prior state to write SQL migrations.
Kysely is a query builder with no schema of its own. You supply a Database interface describing tables and columns, either written by hand or generated by introspecting a live database. Its Migrator runs migrations you write yourself. Nothing keeps the interface and the database in sync except your process.
Prisma: schema.prisma ──generate──▶ client + types
└──migrate diff──▶ SQL ──▶ DB
Drizzle: TS tables ──infer──▶ types
└──drizzle-kit──▶ SQL ──▶ DB
Kysely: migrations (hand-written) ──▶ DB
DB ──introspect or hand-edit──▶ Database interface ──▶ types
Query syntax is easy to adapt to. Switching who owns the schema is not.
PRISMA ─ source of truth: schema.prisma
┌───────────────┐ generate ┌────────────────┐
│ schema.prisma │─────────▶│ client + types │
└───────┬───────┘ └────────────────┘
│ migrate diff
▼
┌───────────────┐ apply ┌──────────┐
│ SQL migration │─────────▶│ database │
└───────────────┘ └──────────┘
DRIZZLE ─ source of truth: TypeScript table definitions
┌───────────────┐ infer ┌────────────────┐
│ TS table defs │─────────▶│ inferred types │
└───────┬───────┘ └────────────────┘
│ drizzle-kit (diff vs. prior state)
▼
┌───────────────┐ apply ┌──────────┐
│ SQL migration │─────────▶│ database │
└───────────────┘ └──────────┘
KYSELY ─ no schema of its own; keeping it in sync is your process
┌─────────────────┐ Migrator ┌──────────┐
│ hand-written │─────────▶│ database │
│ migrations │ └────┬─────┘
└─────────────────┘ │ introspect or hand-edit
▼
┌────────────────────┐ ┌───────┐
│ Database interface │───────▶│ types │
└────────────────────┘ └───────┘
Where the schema lives in each tool: Prisma generates types and migrations from schema.prisma, Drizzle infers types and diffs migrations from TypeScript table definitions, and Kysely derives its Database interface from a database shaped by migrations you write yourself.
How it works: from a method call to SQL on the wire
The schema decides where types come from. At runtime, each tool also takes a different path from your function call to a statement the database executes, and that path affects debugging, bundling and how much you can see.
Prisma. Your code calls a method on the generated client, such as prisma.user.findMany({ ... }). The client turns the call into a query description and hands it to Prisma's query engine. The engine plans the SQL, which may be more than one statement for nested reads. It runs that SQL over a connection pool or through a driver adapter, and it shapes the rows back into the nested objects your types describe. Earlier versions shipped the engine as a native binary or library. Newer releases have been moving that work into TypeScript, so check how your version is packaged. You do not build the SQL yourself. To see it, enable query logging on the client, for example log: ['query'] in the client constructor.
Drizzle. A Drizzle query is a builder object. When you await it, Drizzle renders the builder into a SQL string plus a parameter array and passes both to the driver you configured, such as pg for PostgreSQL. Drizzle maps the returned rows onto the shape the builder declared, and there is no separate engine process. A core-API select with a leftJoin becomes one SQL statement that mirrors what you wrote. The relational API (db.query...) compiles a relation tree into SQL that builds the nested result in the database. You can call .toSQL() on a query to inspect the statement and parameters without running it.
Kysely. Each builder call returns a new immutable builder, which internally holds a tree of operation nodes. On .execute(), plugins such as CamelCasePlugin can transform that tree. The dialect's query compiler then turns the tree into SQL and parameters, and the dialect's driver executes it through the pool you supplied. Results come back as plain rows typed by your Database interface. Calling .compile() returns the SQL and parameters without executing, which is useful in tests and code review.
Prisma: client method ──▶ query engine ──▶ SQL (one or more) ──▶ pool/adapter ──▶ DB
Drizzle: builder ──render──▶ SQL + params ──▶ your driver ──▶ DB
Kysely: builder ──node tree──▶ plugins ──▶ dialect compiler ──▶ SQL + params ──▶ your driver ──▶ DB
In practice, Drizzle and Kysely let you predict the emitted SQL from the code in front of you. Prisma asks you to trust its planner and check the logs when performance matters.
How each tool produces type safety
Each tool gets its types from a different place, and that place decides what can go stale.
Prisma compiles model User { id Int @id @default(autoincrement()) email String @unique } into a generated client with prisma generate. Your editor reads that generated artifact, not the schema file. If a teammate edits schema.prisma and nobody regenerates, the editor and tsc check against the old client. CI can pass on a stale client if it restores cached dependencies without running generation. Make prisma generate an explicit build step.
Drizzle has no generation step. pgTable('users', { id: serial('id').primaryKey(), email: text('email').notNull() }) is an ordinary TypeScript value, and typeof users.$inferSelect derives the row type from it. Types follow your edits immediately. On schemas with many tables and relations, though, the compiler re-derives deep conditional types on every check, which can slow tsc and editor autocomplete.
Kysely trusts an interface you supply, such as interface UserTable { id: Generated<number>; email: string }, registered under users in Database. Nothing ties that interface to the real database. If a column is renamed in a migration and the interface is not updated, the compiler approves a query that fails at runtime. Running a code generator against the database in CI narrows this gap. Kysely's result-type inference over large interfaces can also slow the compiler.
| Type source | Failure mode | CI guard | |
|---|---|---|---|
| Prisma | Generated client | Stale generation | Run generate before tsc |
| Drizzle | Schema objects | Slow inference | Monitor tsc time |
| Kysely | Database interface |
Drift from real DB | Regenerate and diff the interface |
// --- Prisma: schema.prisma (not TypeScript; types come from `prisma generate`)
// model User {
// id Int @id @default(autoincrement())
// email String @unique
// }
// This import resolves to the generated client. If generate was skipped, it is stale or missing.
import type { User as PrismaUser } from '@prisma/client';
// --- Drizzle: the schema is a runtime value, and types are inferred from it
import { pgTable, serial, text } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
});
// Recomputed by the compiler on every check. Nothing is generated.
export type DrizzleUser = typeof users.$inferSelect; // { id: number; email: string }
export type NewDrizzleUser = typeof users.$inferInsert; // id is optional
// --- Kysely: you write (or generate) the interface, and the compiler trusts it
import type { Generated, Selectable } from 'kysely';
interface UserTable {
id: Generated<number>; // the database assigns it, so it is optional on insert
email: string; // if a migration renames this column, nothing here fails
}
export interface Database {
users: UserTable; // the key must match the real table name
}
export type KyselyUser = Selectable<UserTable>; // { id: number; email: string }
// All three describe the same row shape. They differ only in where that shape comes from.
export type SameShape = [PrismaUser, DrizzleUser, KyselyUser];
The same users table defined in Prisma (the schema plus a type imported from the generated client), Drizzle (a pgTable value with $inferSelect) and Kysely (a hand-written Database interface), showing where each tool's types come from.
The same API tasks in Prisma, Drizzle and Kysely
Filtered list with pagination
Prisma expresses the query as an object: findMany with where, orderBy, take and skip (or cursor for keyset pagination). Drizzle's core API reads like SQL: select().from(users).where(...).orderBy(...).limit(...).offset(...), with operators such as eq and and imported as functions. Kysely is similar but closer to SQL text: selectFrom('users').where('status', '=', 'active'), with column names checked against your Database interface.
Joins and nested fetches
This is where the tools differ most. Prisma's include or nested select returns a user with their orders already nested, and you do not have to write a join. Drizzle's leftJoin returns flat rows that you group yourself. Its relational query API (db.query.users.findMany({ with: { orders: true } })) returns nested objects, but only after you declare relations. These are two different mental models with different result shapes. A team that mixes them ends up with inconsistent data-access code and harder reviews, so pick one as the default. Kysely has no relation loading. You write the join, or you build nested JSON with its database-specific helpers such as jsonArrayFrom.
Upsert and transactions
Prisma's upsert takes where, create and update. Drizzle and Kysely expose the database's own conflict clause, for example onConflictDoUpdate in Drizzle or onConflict(...).doUpdateSet(...) in Kysely. All three support callback-style transactions: Prisma's interactive $transaction, Drizzle's db.transaction, and Kysely's db.transaction().execute.
The trade-off is that Prisma saves the most time on CRUD and nested reads. Drizzle and Kysely reward engineers who think in SQL, but they give less guidance to engineers who are not comfortable with joins.
// Assumes prisma, db (Drizzle) and kdb (Kysely) are configured, plus Drizzle schema objects users/orders.
import { eq } from 'drizzle-orm';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
// Prisma: describe the result shape. Orders come back nested, with no join written.
const p = await prisma.user.findMany({
where: { status: 'active' },
include: { orders: true },
orderBy: { id: 'asc' },
take: 20,
skip: 0,
});
// Drizzle core API: a real SQL join. Rows are flat ({ users, orders | null }).
// LIMIT counts joined rows, not users, and you group the rows yourself.
const flat = await db
.select()
.from(users)
.leftJoin(orders, eq(orders.userId, users.id))
.where(eq(users.status, 'active'));
// Drizzle relational API: nested result, but only after relations() are declared.
// Pick this style or the one above as the team default.
const nested = await db.query.users.findMany({
where: (u, { eq }) => eq(u.status, 'active'),
with: { orders: true },
limit: 20,
});
// Kysely: no relation loading. Build the nested array with a Postgres JSON helper.
const k = await kdb
.selectFrom('users')
.select((eb) => [
'users.id',
'users.email',
jsonArrayFrom(
eb.selectFrom('orders').select(['orders.id', 'orders.total']).whereRef('orders.user_id', '=', 'users.id')
).as('orders'),
])
.where('users.status', '=', 'active')
.orderBy('users.id').limit(20).offset(0)
.execute();
The same query, active users with their orders, written in Prisma's include, Drizzle's flat join and relational API, and Kysely's jsonArrayFrom subquery, showing how the result shape and pagination behaviour differ between them.
Migration workflows and review: Prisma Migrate, drizzle-kit and Kysely's Migrator
Prisma Migrate treats schema.prisma as the desired state. Running prisma migrate dev diffs that schema against the migration history, writes a timestamped migration.sql file, and applies it to the development database. In CI or at deploy time, prisma migrate deploy applies pending files in order and does not generate anything. drizzle-kit follows a similar model with a TypeScript schema. drizzle-kit generate compares your table definitions against stored snapshots and emits SQL files, and drizzle-kit migrate applies them. Kysely does not generate migrations. Its Migrator loads modules that export up and down functions, each receiving a Kysely<any> instance. A typical up calls db.schema.alterTable('users').addColumn('last_login_at', 'timestamptz'), and the matching down drops that column. You write both by hand, and migrateToLatest() runs whatever is pending.
Generated SQL still needs review because a diff cannot see intent. When a column is renamed in the schema, the diff sees one column disappear and another appear. Prisma's default output is a drop plus an add, which deletes the data. drizzle-kit asks interactively whether you meant a rename, but the answer is only as good as the person typing it. A type change such as text to integer can fail on existing rows, or it can rewrite and lock a large table.
The practical rule is the same for all three tools: commit the migration file in the same pull request as the schema change, and review the SQL itself rather than the schema diff. Run only the apply step in the deploy pipeline. Kysely asks you to write more of the migration yourself, but there is no generated output that could differ from what you intended.
// migrations/2026_10_27_001_add_last_login_at.ts
// Hand-written migration: the reviewer reads exactly the SQL intent that will run.
import { Kysely } from 'kysely'
export async function up(db: Kysely<any>): Promise<void> {
// Additive, nullable column: no rewrite of existing rows, safe to deploy first.
await db.schema.alterTable('users').addColumn('last_login_at', 'timestamptz').execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable('users').dropColumn('last_login_at').execute()
}
// scripts/migrate.ts: the apply-only step the deploy pipeline runs
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { Pool } from 'pg'
import { Kysely, Migrator, FileMigrationProvider, PostgresDialect } from 'kysely'
async function main() {
const db = new Kysely<any>({
dialect: new PostgresDialect({
pool: new Pool({ connectionString: process.env.DATABASE_URL }), // e.g. postgres://app:changeme@db:5432/app
}),
})
const migrator = new Migrator({
db,
// migrationFolder must be an absolute path
provider: new FileMigrationProvider({ fs, path, migrationFolder: path.resolve('migrations') }),
})
// Runs only pending migrations, in name order; nothing is generated here.
const { error, results } = await migrator.migrateToLatest()
results?.forEach((r) => console.log(`${r.status}: ${r.migrationName}`))
await db.destroy()
if (error) {
console.error('migration failed', error)
process.exit(1) // fail the deploy instead of starting the app on a half-migrated schema
}
}
main()
A hand-written Kysely migration with matching up and down functions, plus the apply-only script a deploy pipeline runs through Migrator.migrateToLatest().
Raw SQL escape hatches and runtime constraints
Every API eventually hits queries the abstraction handles poorly: recursive CTEs, window functions, full-text search, database-specific upsert clauses, or a query you have tuned by hand against EXPLAIN. In most codebases this is a small set of reporting and hot-path queries, not the bulk of CRUD. What matters is how much type safety you keep in those queries.
- Prisma provides tagged-template raw queries (
$queryRaw,$executeRaw) that parameterize interpolated values. You can attach a result type as a generic, but Prisma does not check it against the database. TheUnsafevariants accept plain strings and remove the injection protection, so restrict them in review. - Drizzle has a
sqltemplate tag that works standalone or inside a builder query, so you can drop one expression into an otherwise typedselect.sql<T>declares the result type without verifying it. - Kysely uses the same pattern. Its
sqltag composes into builder expressions, which keeps raw fragments small and the surrounding query typed.
In all three tools, a raw-SQL result type is an assertion, not a check. Cover these queries with integration tests against a real database.
Runtime points to verify
- Prisma artifacts: The generate step must run in CI, and the output must ship in your bundle. Depending on the version, Prisma may also need a native engine built for your deploy platform. Check this per release.
- Footprint: Drizzle and Kysely are thin layers over the driver you choose, so there is less to bundle and cold starts are smaller.
- Pooling: Each serverless instance opens its own connections. Put an external pooler in front, and confirm that its transaction mode works with prepared statements.
- Edge: Some runtimes lack TCP sockets. Confirm that an HTTP- or WebSocket-based driver or adapter exists for your database.
Adopting each tool on an existing database
Every tool connects to an existing database. They differ in how much they generate for you and in how much they expect to own afterwards.
| Prisma | Drizzle | Kysely | |
|---|---|---|---|
| Introspection | prisma db pull writes the schema file |
drizzle-kit's pull command emits TypeScript table definitions | None built in. Use the community kysely-codegen or write the interface by hand |
| Legacy naming | @map / @@map per field and model |
DB name passed as the column's string argument, or a casing option | CamelCasePlugin, or keep the DB names in the interface |
| Baseline for migrations | Mark an initial migration as applied with prisma migrate resolve |
Treat the pulled schema as the starting point before generating diffs | Migrator starts empty and existing tables are left alone |
Prisma's introspection is the most complete, but it rewrites the schema file on every pull. Re-apply hand edits such as relation names and @map renames afterwards and check them in review. Drizzle's pulled output is plain TypeScript, so you can rename keys freely as long as the DB-name string stays put. Kysely has the least tooling, but it also has the least lock-in. A generated or handwritten Database interface only describes types, so an inaccurate column type compiles fine and fails at runtime. Regenerate the interface in CI and diff it against what's committed.
To migrate incrementally, run the new layer alongside the old one and move one module at a time, starting with read-heavy modules. Each layer opens its own pool, so size both against the database's connection limit. A transaction cannot span the two layers, so move code that writes to related tables together. Let only one tool issue DDL until the cutover finishes.
How these tools save engineering time
All three save time in the same broad way. They move a class of mistakes from runtime to compile time or review time, where those mistakes are cheaper to fix. Each tool shifts a different part of the work.
- Compile-time column and type checks. A misspelled column, a wrong filter type or a missing required field on insert fails
tscinstead of failing in staging. This applies to all three tools, provided the types match the real database. That means a current generated client for Prisma, up-to-date table definitions for Drizzle, and a regenerated interface for Kysely. - Refactoring. When the schema lives in TypeScript, as with Drizzle's table objects or a Kysely interface, renaming a column or table property flags every affected call site in the editor. Prisma gives the same result after
prisma generate. - Migration authoring. Prisma Migrate and drizzle-kit write the first draft of the SQL from a schema diff, so the engineer reviews and edits the migration instead of writing it from scratch. Kysely skips this step, which costs authoring time but leaves nothing generated to second-guess.
- Nested reads and CRUD. Prisma's
include, Drizzle's relational API and Kysely's JSON helpers replace hand-written joins and the grouping code that turns flat rows into nested objects. Prisma removes the most code here. - Debuggable SQL. Drizzle's
.toSQL()and Kysely's.compile()let you see the exact statement in a unit test or code review without a database round trip. That shortens performance investigations.
These tools also add time in some places. Prisma's generate step adds CI configuration and causes failures when the client is stale. Large Drizzle and Kysely schemas can slow type checking. And raw-SQL escape hatches still need integration tests, because their result types are not verified.
Illustrative estimate. None of these inputs are measured. Assume a team ships 8 schema changes a month. Writing each migration by hand takes an assumed 30 minutes, and reviewing and adjusting a generated one takes an assumed 10 minutes. Generation then saves about 8 × 20 = 160 minutes a month. Now assume compile-time checks catch 4 column or type mistakes a month that would otherwise surface in staging, at an assumed 45 minutes each to diagnose and fix. That adds about 180 minutes. Against this, assume 30 minutes a month spent on generate-step or type-check-speed issues. Under these assumptions, the net is roughly 5 hours a month. Replace the inputs with your own team's numbers. The result depends heavily on how often your schema changes and how often type mismatches reach a shared environment.
Alternatives to Prisma, Drizzle and Kysely
The three tools sit at different points on one spectrum, and the categories next to them are worth considering before you commit.
- Plain SQL with a driver. Use the database driver directly, for example
pgfor PostgreSQL, with parameterized queries. There is no abstraction to learn, no generation step and no compile-time cost, and every query is exactly what runs. You give up type checking on columns and results unless you add your own row types and keep them in sync. This works well for small services, scripts and teams with strong SQL review. - SQL-first code generation. Some tools take hand-written
.sqlfiles, check them against a live database or schema, and generate typed functions for each query. You write real SQL and still get checked parameter and result types. The cost is a generation step and less flexibility for queries built dynamically at runtime, such as optional filters. - Class-based ORMs with change tracking. Some ORMs model tables as classes, load entities into a unit-of-work or identity map, and write changes back when you save or flush. They suit teams used to that pattern from other ecosystems. They also add implicit behaviour, such as lazy loading and dirty checking, that makes the emitted SQL harder to predict than in Drizzle or Kysely.
- Mixing layers. Many codebases pair one of these tools for routine CRUD with plain SQL, or the tool's own
sqltag, for a few hand-tuned queries. That is often a better outcome than forcing every query through one API.
Compare alternatives on the same axes used throughout this article: where the schema lives, what can go stale, what a migration is, and how much SQL the team is expected to read.
Decision guide: when to use or avoid each tool, and the cost of switching
| Team situation | Lean toward | Why |
|---|---|---|
| Mixed SQL fluency, app team owns a new schema | Prisma | Schema file drives migrations and CRUD; relation loading is built in |
| SQL-fluent team, schema defined in TypeScript | Drizzle | Queries map closely to SQL, and drizzle-kit diffs the TS schema |
| Existing database owned by DBAs or another service | Kysely | Types come from the live schema; no ORM model to keep in sync |
| Low tolerance for a code-generation step | Drizzle or Kysely | Types are inferred at compile time, or generated only when the schema changes |
When to avoid each
Avoid Prisma for SQL-heavy or tuning-heavy workloads. Hand-tuned queries end up in raw SQL, which loses the generated types that justified adopting it. Avoid Drizzle if the team is uncomfortable with SQL. Its API exposes joins and query shape directly, so every review becomes a SQL review. Avoid Kysely if you want an ORM that writes routine CRUD for you and derives migrations from a schema. You write both yourself.
Engineering cost
Prisma has the lowest onboarding cost for generalists, but CI has to run the generate step and keep the client in sync with the schema. Drizzle and Kysely need SQL-literate reviewers. Kysely's hand-written migrations also take longer to review than generated diffs.
Switching cost
Query code, migration history and schema definitions are all specific to one tool. Switching means rewriting all three at once, and baselining a new migration history against production. That makes the first choice a long-term one.
If none of these fit, plain SQL with a driver avoids abstraction entirely, and other ORMs offer different trade-offs on model classes and change tracking. The alternatives section above covers those options.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to prisma vs drizzle vs kysely, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.