Prisma vs Drizzle vs Kysely: Choosing a TypeScript Data Layer
A decision framework for the TypeScript data layer on Postgres: how much SQL the library should own, what it costs on Lambda, and when the default is wrong.
Every new TypeScript service on Postgres forces a decision that is expensive to reverse: how much of the SQL does a library get to own? Prisma, Drizzle, and Kysely answer differently, and the difference is not a feature list. It is about where the types come from and who writes the query. For a new Postgres-backed service on Lambda, the default is Drizzle: it owns the schema and the migrations while still leaving the query shape visible in the code. Two conditions override that default. The serverless objection to Prisma that most comparisons still repeat has expired as well, and the narrower objection that replaced it is easier to miss.
This assumes Postgres is already the answer. If that is still open, How to Choose a Database covers the layer below this one, and the DynamoDB single-table design guide covers the case where a relational store is not the right shape at all.
Version numbers below were checked on 3 August 2026. They move faster than the arguments around them, so re-check before you pin anything.
The axis: who owns the SQL
Place any data-layer tool on a single line and the comparison gets much shorter. At one end you write the SQL and TypeScript checks it. At the other end you describe a schema and the library decides what SQL runs. These three sit at three distinct points on that line.
Kysely is a typed query builder and says so plainly: not an ORM, with no concept of relations. The query reads like SQL because the builder mirrors SQL grammar. Types come from a Database interface that you maintain by hand or generate from the live database with kysely-codegen. It runs on Node.js and also on Deno, Bun, Cloudflare Workers, and browsers, with dialects for PostgreSQL, MySQL, MSSQL, SQLite, and PGlite.
Drizzle is SQL-first. You declare the schema in TypeScript, base types are inferred from those schema objects with no separate codegen step, and the query API stays close to SQL.
Prisma owns the SQL. You declare a schema in a purpose-built DSL, a generator produces a client, and that client decides what SQL runs. In exchange you get a relational API that reads like object graphs.
One query makes the difference concrete: the ten most recent published posts, each with its author’s name.
// Kysely: you write the join, kysely-codegen writes the types
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import type { DB } from "./types/db.js";
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: process.env.DATABASE_URL }),
}),
});
const rows = await db
.selectFrom("post")
.innerJoin("author", "author.id", "post.author_id")
.select(["post.id", "post.title", "author.name as author_name"])
.where("post.published", "=", true)
.orderBy("post.created_at", "desc")
.limit(10)
.execute();
// Drizzle: the TypeScript schema is the source of the types, no codegen step
import { drizzle } from "drizzle-orm/node-postgres";
import { desc, eq } from "drizzle-orm";
import { author, post } from "./schema.js";
const db = drizzle(process.env.DATABASE_URL!);
const rows = await db
.select({ id: post.id, title: post.title, authorName: author.name })
.from(post)
.innerJoin(author, eq(author.id, post.authorId))
.where(eq(post.published, true))
.orderBy(desc(post.createdAt))
.limit(10);
// Prisma 7: the generated client lives at an explicit output path, not in node_modules
import { PrismaClient } from "./generated/prisma/client.js";
import { PrismaPg } from "@prisma/adapter-pg";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
const rows = await prisma.post.findMany({
where: { published: true },
select: { id: true, title: true, author: { select: { name: true } } },
orderBy: { createdAt: "desc" },
take: 10,
});
Read the result shapes rather than the syntax. Kysely and Drizzle both return a flat row carrying author_name, because you wrote the join. Prisma returns a nested object, because the client chose how to fetch the relation. That is the whole trade in one line: convenience at the call site, in exchange for a join strategy you did not specify and cannot change without leaving the abstraction.
Positions on this line do move. Drizzle’s Relational Queries v2 makes its object-graph reading richer. Prisma has argued in its own writing that this is a convergence toward patterns it introduced in 2020, and the evidence it cites is Drizzle’s test suite growing from 600 to over 9,000 tests. That is a vendor writing about a competitor, so weigh it as a claim rather than a finding. The detail worth keeping is what the argument leaves out: Kysely is absent from it, because the query-builder end of the line is not converging with anything.
Why Drizzle is the default
Drizzle is the only one of the three that answers both halves of the problem at once. It owns the schema and the migration story, so you are not assembling a data layer from three unrelated tools. It also keeps the query shape visible in the code, so the SQL you read is close to the SQL that runs. That combination is what survives both a cold-start budget on day one and a performance investigation six months in. drizzle-kit generate emits SQL files a human can review before they execute, the schema lives in the same language as the rest of the service, and there is no engine binary in the artifact.
The version situation is the part stale comparisons miss, and it deserves a decision rather than a default. On npm, drizzle-orm at latest is 0.45.2 and drizzle-kit at latest is 0.31.10, both still on 0.x. Meanwhile the beta tag points at 1.0.0-beta.22 and an rc tag now points at 1.0.0-rc.4, published 27 June 2026. Drizzle v1 has moved from beta into release candidate without taking the latest tag. Most published comparisons still describe v1 as being in beta, which was accurate only a few months ago.
That gap creates a practical trap. The v0 to v1 documentation reads as though v1 is the current version, while a reader who runs npm install drizzle-orm gets 0.45.2. The two readers end up with different libraries and different APIs. Read the v0 to v1 changes before you write the first schema file, not after. Relational Queries v1 was removed rather than extended, and the rest of the list is just as blunt: v2 introduces a new defineRelations() API, the validation integrations consolidate from drizzle-zod, drizzle-valibot, and drizzle-typebox into drizzle-orm/zod and siblings, drizzle-orm/effect-schema is added, the migration folder moves to a v3 format that drops journal.json, and drizzle-kit drop is gone.
The trade-off is therefore adoption timing, not capability. You are picking up a library in the middle of a major transition, and the failure mode is a forced migration on someone else’s schedule. Starting on 0.45.2 means accepting a future Relational Queries v1 to v2 rewrite. Starting on the release candidate means accepting pre-stable churn instead. Pin a specific version either way: an exact version number in package.json, not a caret or tilde range. Write the reason in the repository, so the next person inherits a decision rather than a lockfile.
One related note on the consolidated validation packages: convenient as they are, keep the two type systems distinct. The shape you accept at the API boundary and the shape you store are related, not identical. Schema-first API development with Zod and OpenAPI treats the request side as its own contract, which is the right instinct.
Prisma 7 and the bundle-size objection
The reflex argument against Prisma on Lambda used to be the Rust engine binary. That argument expired on 19 November 2025. Prisma 7.0.0 shipped that day, made the Rust-free client the default, and replaced the prisma-client-js generator with the prisma-client provider. The load-bearing packaging fact is that there is no platform-specific native binary to ship any more. Prisma describes this as a TypeScript rewrite, while third-party coverage describes the query compiler running as a WebAssembly module on the JavaScript thread. For a Lambda artifact the difference between those two descriptions changes nothing; the absence of the native binary changes a great deal.
Prisma’s own benchmark post puts the bundle at roughly 14 MB before and 1.6 MB after, measured on PostgreSQL with the pg driver. Those are vendor numbers from a vendor benchmark, so read them as a direction rather than a guarantee. The direction is not seriously contested: an artifact that no longer carries a compiled engine is a different artifact. Measure your own on the zip Lambda receives rather than on node_modules, since those two numbers routinely disagree; AWS Lambda TypeScript anti-patterns covers why.
The upgrade cost is operational rather than architectural, and it is easy to trip over. In version 7 the generated client moved out of node_modules and now requires an explicit output path in schema.prisma. You import from the generated directory instead of from @prisma/client. The post-install hook was removed, which means prisma generate has to be called explicitly. Docker builds and CI pipelines that relied on the implicit hook will fail, and the error will not obviously point at either change. Driver adapters are now provided explicitly in source, and configuration moved to prisma.config.ts, which is required for introspection and the CLI database workflows.
There is also an honest performance footnote, and it comes from Prisma. The 7.0 announcement leads with claims of 3x faster query execution and a 90% smaller bundle output. Three months later, Prisma’s AMA about the rewrite concedes: “There are also cases where throughput is similar or slightly worse”. The reason it gives is architectural: version 7 talks to the database from JavaScript rather than from a separate multi-threaded Rust runtime. The useful reading is not that one of those numbers is wrong. It is that peak parallelism was deliberately traded for serverless and edge fit. On Lambda, that is the side of the trade you want to be on.
Connection pooling on Lambda
Bundle size is a day-one number. Connections are the constraint that binds in production. Postgres enforces a hard max_connections limit, and Lambda scales horizontally without asking permission. The data-layer choice quietly decides which pooling architecture stays available to you.
The baseline guidance is the same for all three tools, and Prisma documents it clearly. Instantiate the client outside the handler so it survives warm invocations. Do not disconnect at the end of a request, because opening a new connection takes time and slows the function down on every subsequent call. Then set the reserved concurrency below the database connection limit divided by the connections each function holds. In the example below the pool size is 1, so each concurrent function holds one connection. Raise the pool and the same concurrency asks the database for a multiple of that.
// handler.ts: the pool lives outside the handler so warm invocations reuse it
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { post } from "./schema.js";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
// one execution environment serves one request at a time;
// raise this only if a single handler fans out parallel queries
max: 1,
});
const db = drizzle(pool);
export const handler = async () => {
const rows = await db.select().from(post).limit(10);
return { statusCode: 200, body: JSON.stringify(rows) };
};
The AWS-specific catch is where the three tools stop being interchangeable. Standard serverless advice is to put RDS Proxy in front of Postgres, and for most clients that advice holds. Prisma’s deployment documentation says something narrower. Prisma ORM is compatible with RDS Proxy, but there is no benefit in using it for connection pooling. Prepared statements of any size cause RDS Proxy to pin the session, and Prisma uses prepared statements for every query. A pinned session is a session that is not being shared, which was the entire point of the proxy.
That mechanism is about prepared statements, not about Prisma. postgres.js also uses prepared statements by default and pins the same way, while pg requires opting in by naming the statement. So the question to ask during design is not which library is fastest. It is which pooling architecture you are committing to. If RDS Proxy is load-bearing in your design, verify the client’s prepared-statement behaviour before you build around it. If it is not, PgBouncer in transaction mode or a serverless-friendly Postgres removes the constraint instead: how Aurora Serverless v2 works and the Aurora versus RDS trade-offs cover that side of the decision.
Schema ownership and migrations
Prisma and Drizzle both generate migrations from a schema you declare. Kysely inverts the direction: the database is the source of truth, and the types are generated from it. Neither direction is free, and this is the part most teams skip during an evaluation spike.
Prisma pairs its schema DSL with prisma migrate and its dev and deploy split. Drizzle pairs a TypeScript schema with drizzle-kit generate and drizzle-kit migrate, producing SQL files you can read and edit before they run. Kysely owns no schema at all. Its Migrator executes hand-written up and down functions in alphanumeric order, with database-level locking so a given migration runs only once. An allowUnorderedMigrations option exists for teams adding migrations on parallel branches. Nothing is generated from a schema. The kysely-ctl CLI is optional and, in the project’s own framing, not part of the core. Most Kysely teams therefore pair it with kysely-codegen, which reads DATABASE_URL and generates the Database interface.
The failure modes are symmetric. A declared-schema tool can fail to express a change its DSL does not model, at which point you write raw SQL inside a generated migration anyway. A database-first tool drifts the moment somebody forgets to re-run codegen, and the type system will confidently report that everything is fine. Specifically for Kysely, the mitigation is running kysely-codegen in CI and failing the build on a diff, which turns a silent drift into a red pipeline.
Escape hatches to raw SQL
All three have one. What differs is what you give up when you take it. Kysely’s sql template composes natively with the builder, because the builder is already SQL-shaped. Drizzle’s sql template behaves similarly. Prisma’s $queryRaw steps outside the client’s model, which means it also steps outside the client’s result typing unless you annotate the return type by hand. TypedSQL narrows that gap by type-checking SQL files against the schema.
The visible-SQL argument earns its keep during an investigation rather than during the initial build. When a hot endpoint turns slow, the distance between the code you read and the SQL the database executed is the length of your debugging loop. Systematic query profiling is much shorter work when the query in the source is the query in the plan.
The decision flow
The tree below is rooted at the default rather than fanning out neutrally. Every branch is an override condition, and each one is a statement about your team and your database, not about library quality. The order of the two questions is deliberate. Schema ownership is the hardest of the two to change within six months, while a team’s SQL comfort can shift over time, so ownership is asked first.
The same three positions, laid out against the dimensions that tend to decide the argument:
| Dimension | Kysely 0.29.4 | Drizzle 0.45.2 / 1.0.0-rc.4 | Prisma 7.9.1 |
|---|---|---|---|
| Category | Query builder, not an ORM | SQL-first ORM | ORM, the client owns the SQL |
| Type source | Database interface, generated by kysely-codegen from the live database | Inferred from the TypeScript schema | Generated client from the DSL |
| Schema ownership | None, the database is the source of truth | TypeScript schema files | schema.prisma DSL |
| Migrations | Hand-written up and down, kysely-ctl optional and outside the core | drizzle-kit generate, reviewable SQL files | prisma migrate |
| Relations | No concept of relations | Relational Queries v2, defineRelations() | Relational API, the original of the pattern |
| Emitted SQL | You wrote it | Close to what you wrote | The library’s decision |
| Escape hatch | sql template, composes natively | sql template | $queryRaw, plus TypedSQL |
| Runtime dependencies | None | Driver only | Driver adapter, no native binary since v7 |
| Stability | 0.x, steady | 0.x on latest, v1 in release candidate | Stable major, 7.x |
| Licence | MIT | Apache-2.0 (drizzle-orm), MIT (drizzle-kit) | Apache-2.0 |
When to override the default
Override 1: the team does not write SQL fluently, and the schema is the domain model. Use Prisma. The relational API is genuinely faster to move in when the mental model is object graphs, and the objection that used to disqualify it on Lambda no longer applies. Check the pooling story before you commit, though. If your architecture assumes RDS Proxy, Prisma’s own documentation says you will get no pooling benefit from it, so plan for PgBouncer-style pooling or a serverless-friendly Postgres instead.
The trade-off you are accepting is query control in exchange for velocity. The failure mode arrives as a single endpoint whose emitted SQL you cannot change without stepping outside the abstraction, and by then the abstraction is load-bearing across the codebase. On AWS the second failure mode is discovering the proxy pinning behaviour after the pooling architecture is already built and deployed.
Override 2: the database is legacy, shared, or owned by another team, or you want the smallest dependency surface you can get. Use Kysely with kysely-codegen, and choose a migration tool separately. When you do not own the schema, generating types from the live database is the correct direction of travel, and Kysely is the only one of the three designed for that direction. Its npm entry lists devDependencies only, so it carries no runtime dependencies, which is the cleanest story available for a Lambda bundle.
The trade-off is that you take on schema drift and migration tooling yourself. The failure mode is a Database interface that stopped matching production three deploys ago while the type checker reported success the whole time. A secondary risk is maintenance concentration: Kysely sits at roughly 14.1k GitHub stars, with a small maintainer group and no visible corporate sponsor behind it. That is a fair question to ask out loud rather than discover later, and it cuts both ways, since a small dependency surface is also a small thing to fork.
Why not TypeORM or Sequelize
The lazy version of this section is that both are old, and for TypeORM that version is now wrong. TypeORM shipped 1.0.0 on 19 May 2026, its first major release since the project started in 2016, followed by 1.1.0 on 13 July 2026, while the 0.3.x branch continues to receive maintenance. The revival is measurable rather than rhetorical. New maintainers took over at the end of 2024. Across 2025 the project published 8 patch versions, merged 575 pull requests against 63 the year before, and closed more than 2,300 issues, on roughly 2 million weekly downloads. Version 1.0 also modernised the stack, moving to a Node.js 20 minimum, mysql2 in place of mysql, better-sqlite3 in place of sqlite3, native crypto for hashing, and parameterized queries across all drivers.
So the honest objection to TypeORM is design, not abandonment. It is decorator-driven and entity-centric, which puts a class hierarchy between you and the SQL. That sits oddly inside a codebase where the rest of the type safety comes from inference. That is a coherent thing to dislike and a poor reason to migrate away from working code: a team already running TypeORM has less reason to move now than it did before 1.0 landed.
Sequelize is a different case. latest is 6.37.8, released 9 March 2026, while v7 remains in alpha at 7.0.0-alpha.48, released 4 February 2026. A major version that has stayed in alpha across 48 releases is the whole signal, and it is enough on its own. The design objection sits on top of it: Sequelize’s TypeScript support is retrofitted rather than native, which is exactly the property you are trying to buy when you pick a data layer for a TypeScript service.
Why not plain pg or postgres.js
Raw SQL on a driver is a legitimate answer, and a comparison that pretends otherwise loses credibility. It wins real things: no abstraction cost, no version treadmill, and the query you wrote is the query that runs. It is also not an either/or at the dependency level, because pg and postgres.js sit underneath all three tools above. You are not choosing between a driver and a library; you are choosing whether to add a layer on top of a driver you will ship regardless.
What you give up is narrower than the usual argument suggests. It is not injection safety, since parameterised queries handle that on any of these paths. It is three specific things: result typing, refactor safety when a column is renamed, and composition when filters are conditional. That third one is the honest reason most teams end up with a builder. Hand-assembling a conditional WHERE clause from string fragments is where raw-SQL codebases turn into something nobody wants to modify. It happens gradually enough that no single commit looks like the mistake.
Common pitfalls
- Evaluating on
findManywhen the risk lives in a five-join aggregate. Every tool looks fine on a three-table select; benchmark the query that worries you. - Measuring bundle size on local
node_modulesinstead of on the deployed artifact. Those numbers diverge, and only one of them affects cold starts. - Assuming the type layer is checked against the live database when it is checked against a file a human keeps in sync. That is true for Kysely by design, and true for the others any time codegen is skipped.
- Upgrading to Prisma 7 without noticing that the generated client left
node_modulesand the post-install hook is gone. The Docker or CI failure that follows points nowhere useful. - Treating connection pooling as a data-layer feature rather than an infrastructure decision, then finding out the chosen client’s prepared-statement behaviour defeats the proxy in front of it.
- Repeating a pre-7.0 comparison’s claims about Prisma’s Rust engine. That architecture stopped being the default on 19 November 2025.
What to measure
Pick the measurements before the migration, because afterwards every number reads as a justification.
- Deployed artifact size in MB, taken from the zip Lambda receives rather than
duonnode_modules. - Cold-start initialisation time in ms, read from
INIT_DURATIONin CloudWatch rather than from local timings. - Query latency at p95 per endpoint, with the emitted SQL captured alongside it so the two can be read together.
- Active database connections at peak concurrency, against
max_connections. - How many places the team dropped to raw SQL in the first quarter. This is the single best signal that the abstraction was mismatched to the workload.
- Whether a human read the migration SQL before it ran. It is binary, unglamorous, and more predictive of incidents than any latency number on this list.
The default holds for the common case: a new Postgres-backed TypeScript service, on Lambda, owned end to end by the team that writes it. Drizzle at a pinned version gives you schema, migrations, and visible SQL without an engine binary in the artifact. Move off it deliberately in two situations. Reach for Prisma when the schema is the domain model and the team would rather describe data than query it, having first confirmed that the pooling design does not depend on RDS Proxy. Reach for Kysely when the database belongs to someone else, and types have to flow out of it rather than into it. Write down which of the three cases you are in, because in six months that sentence will be worth more than the benchmark.
References
- Prisma ORM v7.0.0 release notes - Primary source for the 19 November 2025 release date, the Rust-free client becoming the default, and the breaking changes including the generated-client output path
- Prisma 7 Release: Rust-Free, Faster, and More Compatible - Headline performance and bundle claims, and the
prisma-clientprovider change - Prisma ORM without Rust: Latest Performance Benchmarks - The 14 MB to 1.6 MB bundle figures, with the PostgreSQL and
pgdriver benchmark methodology - Prisma 7 AMA: Clearing Up the Why Behind the Changes - The vendor conceding throughput regressions in some scenarios, which balances the marketing numbers
- Caveats when deploying to AWS platforms - The RDS Proxy session-pinning statement and the prepared-statement mechanism behind it
- Prisma database connections - Serverless connection guidance, the concurrency limit formula, and the PgBouncer and
DIRECT_URLpattern - Plot Twist: We’re All Building the Same ORM - Vendor-authored argument that Drizzle and Prisma are converging, cited here as an attributed claim rather than a neutral finding
- Drizzle ORM v0 to v1 updates - The authoritative list of v1 breaking changes, package layout, and migration folder format
- Drizzle Relational Queries v1 to v2 - The
defineRelations()migration path, the change most likely to affect a team starting on 0.45.2 - Drizzle ORM releases - Release cadence and the beta to release-candidate progression toward 1.0
- Kysely on GitHub - Supported dialects, runtime support, and the maintainer picture
- Kysely relations recipe - The project’s own statement that Kysely is not an ORM and has no concept of relations
- Kysely migrations - The minimal migrator, hand-written
upanddown, execution locking, andkysely-ctlsitting outside the core - TypeORM releases - 1.0.0 on 19 May 2026 and 1.1.0 on 13 July 2026, with absolute dates in the release feed
- TypeORM Reaches 1.0 after Nearly a Decade - The maintenance-revival numbers and what changed in 1.0
- Sequelize releases - v6.37.8 as the stable line and v7.0.0-alpha.48 from 4 February 2026 as the still-alpha v7
Related posts
A measured benchmark of 9 bundlers and 3 cdk synth runners for CDK TypeScript Lambdas, with a per-layer default and the rule that picks each one.
AppSync subscriptions fire only on mutations. This explores bridging downstream BFF events into a NONE-data-source mutation with EventBridge and CDK.
Match architecture weight to each runtime's init-amortization: lean handlers on single-purpose Lambda, more on a Lambdalith, full OOP/DI only on long-lived runtimes.
How to slice AWS Lambda functions: default to single-purpose, treat the single-domain Lambdalith as an earned exception, and the platform forces that decide it.
DI containers, monolithic SDKs, god-handlers, top-level secret fetches, and heavy ORMs - what they cost on cold start, and the functional shape that replaces them.