Prisma vs TypeORM vs Drizzle: a fair benchmark, every caveat, every footgun

Yes, that's a huge post. You don't have to read it all — the TL;DR is in the final results section. But if you care about the details, the "why" behind the numbers, and the concrete optimizations you can apply today, read on.

Every "Prisma vs X" benchmark online suffers from one of two problems: the author tested the Object-Relational Mappers (ORMs) doing different work (one fetches a count, the other doesn't), or they ran on a laptop that thermal-throttled mid-run. I wanted to know, with everything actually equal: which ORM is faster, by how much, and why?

The thing that pushed me to actually run my own bench was TypeORM vs Prisma — Same Schema, Different Performance Profile. It's a decent read, but the methodology has the usual problems: ran on a developer laptop, single ORM-vs-ORM scenario per query, no pg_stat_statements to verify what SQL was actually emitted, and the conclusions glossed over whether the two ORMs were doing the same logical work. I wanted apples-to-apples — same Postgres, same network, same payload, scenarios run alternately, with the actual emitted SQL verified.

Prisma's own team also published Performance Benchmarks: Comparing Query Latency across TypeScript ORMs & Databases, which is a more honest take from the obvious-conflict-of-interest party. They tested Prisma vs TypeORM vs Drizzle across RDS / Supabase / Neon, 14 queries × 500 sequential reps × 1k-row dataset, executed from a single EC2 instance. Their conclusion: "it depends, mostly similar ballparks." Worth reading, but the gaps that interest me aren't covered by their setup:

  • 1k records is tiny — real perf characteristics (cache misses, index scans, planner choices) only kick in at 10-100k rows.
  • 500 sequential reps measure single-thread latency, not behavior under concurrency. ORMs with worse connection-pool ergonomics (or query engine contention) only fall apart with 100+ Virtual Users hitting at once.
  • Median only, no p95/p99. Tail latency is where the interesting differences live.
  • No memory tracking. Spoiler from this post: that's where Prisma loses hardest.
  • No transactions, complex filters, or N+1 trap scenarios.

So this post is the version I wished existed: 12 scenarios including the stress cases, p95 + p99 + memory captured, real concurrency (500 VUs), real network latency, three ORMs side-by-side, and a verified SQL trail via pg_stat_statements.

Drizzle was tested on the brand-new 1.0.0-rc.1, not the older 0.44.x line. The v1 release rewrites the relational query engine, and the numbers below reflect that — they're significantly better than what 0.44.x produces (a side note at the end compares).

Spoiler — final tally:

  • TypeORM wins 6/12 scenarios on p95 latency. The "boring leftJoinAndSelect + cartesian dedup app-side" approach is still the safe bet for relation-heavy workloads.
  • Drizzle v1 RC wins 3/12 outright (cursor, complex-filter, delete) and ties 3 more (simple-lookup, fulltext, bulk-insert). The biggest surprise: Drizzle's complex-filter is 28x faster than TypeORM's (2.1s vs 60s) thanks to its query planner emitting a cleaner EXISTS subquery.
  • Prisma wins 2/12 (pagination, raw-aggregation) — entirely thanks to the patches in this post.
  • One scenario (fulltext) is effectively a three-way tie — all three within 1% of each other on identical raw SQL.

If you came here looking for "which ORM is fastest" — none of them, all the time. If you came for "what does each one cost you in real numbers and why," read on.

This post is the journey: setup, what I found, what I had to fix to make Prisma fair, and the concrete optimizations you can apply today. Prisma needs the most repair work — it gets the longest "rounds" treatment. TypeORM and Drizzle have caveats too, called out in their own sections later.

Source code + raw bench data: github.com/zaxovaiko/prisma-typeorm-drizzle-benchmark — clone, point at your own Postgres, reproduce.

Caveats and limitations

Read the numbers with these in mind:

  • RUNS=1. Every scenario was run once for 100 seconds with a 0→500-VU ramp. Enough samples for stable p95s on the busy scenarios (thousands of requests), but the small ones (fulltext, raw-aggregation, complex-filter — only ~10 RPS) collected ~1k samples each, so their p95 has visible run-to-run noise. Treat sub-10% gaps in those rows as ties.
  • Single-instance Postgres. No replicas, no PgBouncer, no connection pool in front. Real production setups change the picture, especially under heavier write workloads.
  • Hobby-tier shared CPU. Bigger boxes will reduce absolute latency but the ORM ratios should hold — this bench stresses the ORM/wire path, not raw IO.
  • 20k posts / 100k comments is "medium." At 1M+ rows, LATERAL+json_agg may scale better and leftJoinAndSelect may scale worse. Numbers describe medium datasets — your mileage will vary at the extremes.
  • Latency is the response from API → k6. Not pure SQL execution; includes ORM CPU, JSON serialization, NestJS request lifecycle. By design (it's what your users feel).
  • Drizzle RC, not stable. 1.0.0-rc.1 is a release candidate. The numbers should be close to 1.0 stable but minor regressions/improvements between RC and GA are possible.
  • One outlier I haven't fully explained. TypeORM's raw-aggregation p95 is 2-3x slower than Prisma's and Drizzle's despite identical raw SQL. Probably a connection-pool contention edge case under that scenario's load shape. I'd want a 5x re-run to confirm before drawing a firm conclusion. Treat as suspect.

Table of contents

Setup

  • All three Application Programming Interfaces (APIs) are NestJS 11, identical routes, deployed to Railway (so my laptop battery doesn't influence the numbers).
  • Same PostgreSQL 18 instance with three databases (bench_prisma, bench_typeorm, bench_drizzle), seeded byte-for-byte identically via raw SQL: 2k users, 20k posts, 100k comments, 50 categories.
  • Load generator: k6 running inside Railway's private network (sub-millisecond Round-Trip Time, abbreviated RTT, to the APIs). Scenarios ramp 0 → 500 Virtual Users (VUs) over 100s.
  • Results stored in a separate bench_runs table on the same PostgreSQL. pg_stat_statements enabled on all three databases to capture actual emitted SQL.
  • Metrics captured: avg, p50/p95/p99 (the 50th/95th/99th percentile latency), rps (requests per second), fail_pct, plus peak RSS (Resident Set Size — total memory the process holds in RAM; polled /metrics endpoint exposing process.memoryUsage() every 2s).

Hardware / hosting specs

ResourceSpec
Regioneurope-west4-drams3a (all services pinned, sub-ms private network)
PlanRailway Hobby ($5/seat) — 8 vCPU / 8 GB RAM cap per service, shared CPU
API runtimeNode.js 24 (Alpine) on Railway's container builder, 1 replica each
PostgreSQLghcr.io/railwayapp-templates/postgres-ssl:18, single instance, 5 GB volume
k6 runnerAlpine + k6 + jq + psql, one-shot job, same private network

ORM versions

ORMLibraryAPI style used
Prismaprisma@7.x + @prisma/adapter-pgfindMany/findUnique with include (+ relationLoadStrategy: 'join' after Round 2)
TypeORMtypeorm@0.3.28createQueryBuilder with leftJoinAndSelect
Drizzledrizzle-orm@1.0.0-rc.1Relational query API: db.query.posts.findMany({ with: { ... } }), filter-object syntax (where: { id: { lt: ... } })

I deliberately did not scale up. Hobby-tier shared CPU with 8 GB RAM is what most people running a side project or a small SaaS on Railway will actually have. Bigger boxes will reduce absolute latency for all three ORMs but the ratio between them stays similar — this benchmark stresses the ORM/wire path, not raw CPU/IO.

12 scenarios cover:

#ScenarioWhat it tests
1simple-lookupPK lookup
2relationspost + author + comments + categories
3paginationoffset pagination + count
4fulltexttsvector search
5n-plus-1naive loop vs batched window fn
6bulk-insert20-row INSERT
7deep-nested3-level eager load
8raw-aggregationGROUP BY
9cursorkeyset pagination
10complex-filterOR + range + EXISTS
11deletebulk DELETE WHERE
12transactionatomic multi-write

Round 1: the pagination shocker (Prisma only)

First run, naive code on all three sides. TypeORM and Drizzle were tight; Prisma's pagination scenario was 4x slower. p95 of 12.5 seconds vs ~3 seconds. I expected a small gap, not a chasm.

I enabled pg_stat_statements and looked at what Prisma was actually sending:

1SELECT post.*, COALESCE("aggr_selection_0_Comment"."_aggr_count_comments", 0)
2FROM "Post"
3LEFT JOIN (
4  SELECT "Comment"."postId", COUNT(*) AS "_aggr_count_comments"
5  FROM "Comment" WHERE $4=$5  -- no-op constant filter
6  GROUP BY "Comment"."postId"
7) "aggr_selection_0_Comment" ON ...
8ORDER BY "createdAt" DESC LIMIT 50 OFFSET ?
9

The _count: { select: { comments: true } } field in Prisma's include triggers a full GROUP BY over the entire Comment table (100k rows in our seed) on every paginated request. The 50 posts on the page don't matter — Prisma aggregates everything, joins it, then throws 99.99% of the result away.

Caveat #1: never use Prisma's _count in include on hot paths. Replace it with a separate count query keyed to the page IDs:

1const posts = await prisma.post.findMany({ skip, take, orderBy, include: { author: true } });
2const counts = await prisma.$queryRawUnsafe<{ postId: number; n: bigint }[]>(
3  `SELECT "postId", COUNT(*)::bigint AS n FROM "Comment" WHERE "postId" = ANY($1::int[]) GROUP BY "postId"`,
4  posts.map(p => p.id),
5);
6const byPost = new Map(counts.map(c => [c.postId, Number(c.n)]));
7return posts.map(p => ({ ...p, commentsCount: byPost.get(p.id) ?? 0 }));
8

After this fix, Prisma's pagination dropped from 12.5s to 339ms p95 — actually the best of all three in the final run. (TypeORM 662ms because of its SELECT DISTINCT overhead; Drizzle 618ms.)

Round 2: the JOIN-heavy gap (Prisma only)

With pagination fixed, the next big gap was on relation-heavy scenarios. TypeORM's flat leftJoinAndSelect was the fastest by a clear margin. pg_stat_statements showed Prisma issuing 3 separate queries for relations, even though the relationJoins preview feature was enabled in schema.prisma:

1-- 3 round-trips
2SELECT * FROM "Post" WHERE id=$1 LIMIT 1;
3SELECT * FROM "Comment" WHERE "postId"=$1 ORDER BY ... LIMIT 20;
4SELECT * FROM "Category" INNER JOIN "_PostCategories" ON ... WHERE B=$1;
5

TypeORM emits one query with all LEFT JOINs. Drizzle v1's relational API emits one query with LATERAL subqueries that build nested json_agg results.

Caveat #2: relationJoins in previewFeatures is opt-in per query. Default relationLoadStrategy is still 'query' (separate queries). You have to add relationLoadStrategy: 'join' to every call you want optimized:

1this.prisma.post.findUnique({
2  where: { id },
3  include: { author: true, comments: { ... }, categories: true },
4  relationLoadStrategy: 'join',  // <-- without this, 3 round-trips
5});
6

For n-plus-1, the fix is even more direct: replace the loop with a window-function query:

1const rows = await prisma.$queryRawUnsafe(
2  `SELECT * FROM (
3     SELECT c.*, ROW_NUMBER() OVER (PARTITION BY c."postId" ORDER BY c."createdAt" DESC) rn
4     FROM "Comment" c WHERE c."postId" = ANY($1::int[])
5   ) t WHERE rn <= 3`,
6  postIds,
7);
8

After these patches, Prisma's relations scenario lands at 339ms p95 vs TypeORM's 221ms — much closer than before but not equal.

Why "single JOIN vs N+1" matters: RTT × N

The reason TypeORM wins JOIN-heavy scenarios at sub-millisecond-RTT isn't the query engine — it's just that N round-trips of even 0.1ms each beat one JOIN with a 200-row cartesian product. RTT dominates when query execution time is small.

Inverse: when rows are wide (long bodies, many joined relations), the cartesian explosion of the mega-JOIN can outweigh N round-trips. Drizzle bets on LATERAL+json_agg to side-step the cartesian. v1 RC made this much faster than 0.44.x — see the comparison section below.

Round 3: SQL quirks (Prisma only)

After the structural fixes, I dug into the actual SQL Prisma emits character-by-character. Several quirks add up on hot paths:

Quirk #1: findUnique adds LIMIT $2 OFFSET $3 instead of inline LIMIT 1.

1-- Prisma findUnique({ where: { id } })
2SELECT * FROM "User" WHERE "id"=$1 AND $4=$5 LIMIT $2 OFFSET $3
3-- TypeORM findOne({ where: { id } })
4SELECT * FROM "User" WHERE "id"=$1 LIMIT 2
5-- Drizzle db.query.users.findFirst({ where: { id } })
6SELECT * FROM "User" WHERE "id"=$1 LIMIT 1
7

Prisma binds the LIMIT/OFFSET as parameters, which prevents PostgreSQL (PG) from picking the unique-index fast path. Fix: use findFirst instead of findUnique when you don't need uniqueness validation.

Quirk #2: cursor pagination wraps in a subquery.

1-- Prisma cursor: { id: $1 }, skip: 1
2SELECT * FROM "Post"
3WHERE "id" <= (SELECT "id" FROM "Post" WHERE ("id") = ($1))
4ORDER BY "id" DESC LIMIT $2 OFFSET $3
5-- TypeORM where('p.id < :cursor')
6-- Drizzle where: { id: { lt: cursor } }
7SELECT * FROM "Post" WHERE "id" < $1 ORDER BY "id" DESC LIMIT $2
8

Prisma's cursor: API wraps your cursor value in a subquery to convert from "starts at this row" to a comparable predicate. Fix: don't use the cursor: API. Use where: { id: { lt: cursor } } directly.

Quirk #3: tautologies in WHERE. AND $4=$5 appears in many Prisma WHERE clauses. The query engine emits these as no-ops to keep the parameter count consistent across optional fields. PG's planner ignores them, but it does parse and plan around them. TypeORM and Drizzle emit none of these.

Quirk #4: LIKE casts to text.

1-- Prisma deleteMany({ where: { title: { startsWith: $stamp } } })
2DELETE FROM "Post" WHERE "title"::text LIKE ($2 || $3)
3-- TypeORM/Drizzle equivalent
4DELETE FROM "Post" WHERE "title" LIKE $1
5

The explicit ::text cast and string concatenation prevent the planner from using a text_pattern_ops index. Fix: there isn't one in the public API; either accept it or drop to raw SQL for hot DELETE paths. (This is partly why Prisma's delete scenario lands at 502ms p95 vs TypeORM's 84ms and Drizzle's 77ms — 6x slower.)

How Drizzle handles each round without a fix

The interesting thing about Drizzle: it doesn't need any of Prisma's fixes. Out of the box, Drizzle:

  • Has no _count magic. You write a separate count query yourself (which is what Round 1's "fix" forces Prisma into anyway).

  • Joins are explicit. with: { author: true, comments: { with: { author: true } } } always emits one query — no 'query' vs 'join' strategy toggle.

  • Cursor pagination is just where: { id: { lt: cursor } }. Emits WHERE "id" < $1 directly:

    1-- Drizzle cursor query (emitted)
    2SELECT "id", "title", "body", "authorId", "createdAt"
    3FROM "Post" WHERE "id" < $1 ORDER BY "id" DESC LIMIT $2
    4
  • Pagination is also flat, with one LEFT JOIN LATERAL for the author:

    1-- Drizzle pagination + author (emitted)
    2SELECT "posts"."id", "posts"."title", ..., "posts_author"."data" AS "author"
    3FROM "Post" "posts"
    4LEFT JOIN LATERAL (
    5  SELECT json_build_array("posts_author"."id", "posts_author"."email", ...) AS "data"
    6  FROM (SELECT * FROM "User" WHERE "id" = "posts"."authorId" LIMIT 1) "posts_author"
    7) "posts_author" ON true
    8ORDER BY "posts"."createdAt" DESC LIMIT $2 OFFSET $3
    9
  • No tautologies. Drizzle's SQL builder concatenates exactly the predicates you write.

  • No ::text casts on LIKE. like(posts.title, pattern) emits raw LIKE.

The killer feature in v1 RC: filter-object syntax for relational queries. You write:

1db.query.posts.findMany({
2  where: {
3    AND: [
4      { OR: [{ title: { ilike: pattern } }, { body: { ilike: pattern } }] },
5      { createdAt: { gte: since } },
6    ],
7  },
8  orderBy: { createdAt: 'desc' },
9  limit: 50,
10})
11

— and Drizzle emits a single, well-structured query. No imports needed for operators (eq, lt, desc); the filter object handles them. This is what makes complex-filter so fast (see results).

Final results

12 scenarios, RUNS=1, all three ORMs running side-by-side with Prisma's fair-ish patches applied (relationLoadStrategy, window-fn n+1, batched count, findFirst, direct cursor):

p95 latency by scenario — fast

p95 latency — heavy scans

Requests per second

Peak RSS memory

ScenarioPrisma p95TypeORM p95Drizzle v1 p95Prisma RSSTypeORM RSSDrizzle RSSp95 winner
simple-lookup234ms150ms151ms255 MB230 MB216 MBTypeORM (Drizzle tied)
relations339ms221ms363ms679 MB344 MB360 MBTypeORM
pagination339ms662ms618ms931 MB339 MB348 MBPrisma
fulltext8694ms8614ms8693ms135 MB106 MB101 MBtie (within 1%)
n+1201ms188ms437ms311 MB215 MB267 MBTypeORM
bulk-insert70ms17ms18ms239 MB231 MB205 MBTypeORM (Drizzle tied)
deep-nested166ms132ms855ms688 MB324 MB334 MBTypeORM
raw-aggregation14082ms33986ms20315ms125 MB103 MB101 MBPrisma (TypeORM is the outlier)
cursor587ms294ms274ms358 MB347 MB321 MBDrizzle
complex-filter42530ms59996ms2115ms122 MB105 MB267 MBDrizzle (28x faster)
delete502ms84ms77ms236 MB215 MB203 MBDrizzle
transaction221ms115ms116ms297 MB229 MB222 MBTypeORM (Drizzle tied)

Score: TypeORM 6, Drizzle v1 3, Prisma 2, fulltext tied. Drizzle ties TypeORM in 3 more (simple-lookup, bulk-insert, transaction). Memory: Drizzle wins 7/12, TypeORM wins 5/12.

The headline number is complex-filter: Drizzle v1 finishes in 2.1 seconds while TypeORM grinds for 60 seconds on the same workload. Looking at pg_stat_statements, Drizzle emits a tighter EXISTS subquery that PG's planner handles much more efficiently. TypeORM's createQueryBuilder version produces a query the planner struggles with under load.

What changed between Drizzle 0.44.7 and 1.0.0-rc.1

Before this rerun, I had Drizzle 0.44.7 numbers — and they were noticeably worse than TypeORM on JOIN-heavy scenarios. v1 RC closed most of that gap:

Scenario0.44.7 p95v1 RC p95Δ
simple-lookup224151-33%
relations765363-53%
pagination1331618-54%
fulltext78158693+11% (noise)
n+1514437-15%
bulk-insert2218-18%
deep-nested1465855-42%
raw-aggregation1068920315+90% (slower)
cursor585274-53%
complex-filter530462115-96%
delete10277-25%
transaction150116-23%

The v1 query engine rewrites how LATERAL+json_agg is generated and processed. JOIN-heavy paths see 33-54% latency drops. The complex-filter scenario is the headline: 96% faster because v1's filter-object syntax produces a much cleaner EXISTS subquery for the planner.

The one regression: raw-aggregation got 90% slower in v1. Identical raw SQL; the change must be in the driver/cursor layer. Worth a separate investigation — flagged as a v1 RC issue (could be fixed before GA).

If you're on Drizzle 0.44.x in production, the v1 upgrade is worth scheduling once it leaves RC — the JOIN gains are substantial.

Why Prisma uses 2-3x more memory

On JOIN-heavy paths, Prisma's peak RSS hit 679-931 MB vs TypeORM's 324-344 MB and Drizzle v1's 334-360 MB. Same data, same logical operation. Why?

  1. Generated client size. Prisma codegens model classes with method overloads for every variation (findUnique, findFirst, findMany, aggregate, groupBy, etc. × every relation × every type combination). Bundle is ~5-10 megabytes of JavaScript (JS) loaded into the V8 heap. TypeORM has runtime metadata + entity classes — much smaller resident set. Drizzle has no codegen at all — schema is just TS exports. Smallest constant footprint of the three.

  2. Query engine layer. Prisma 7 with adapter-pg removed the Rust binary, but the JS query engine that builds an Abstract Syntax Tree (AST) → SQL → executes → re-hydrates is still there. AST objects + intermediate query state are held in heap during every request.

  3. Result hydration. Prisma converts every row through generated type validators, allocating new objects with prototypes per model. TypeORM uses entity classes that the pg driver fills directly. Drizzle returns plain objects (or whatever shape the relational query API builds via SQL json_agg) — fewest allocations of the three.

  4. relationLoadStrategy: 'join' cartesian buffering. When you opt into JOIN strategy, the raw cartesian rows arrive (post × categories × comments × commentAuthor = hundreds of duplicate rows per single post lookup), get buffered in JS arrays, then deduplicated into nested structures. Peak heap = full cartesian materialized at once. TypeORM has the same problem (leftJoinAndSelect). Drizzle sidesteps it via LATERAL+json_agg at the SQL level.

  5. No streaming. Prisma's findMany always fully materializes the result set into a JS array before resolving the promise. 100k rows × 1 KB each = 100 MB in heap at peak, even if you only iterate once and throw it away. TypeORM offers qb.stream() (Node.js Readable, backpressure-aware) and qb.iterate() (async iterator), so you can pipe rows from Postgres → HTTP response one at a time and keep memory flat regardless of dataset size. Drizzle exposes the underlying pg cursor for streaming. Prisma's only escape hatch is manual chunked pagination (take/skip loop) or dropping to pg's native Cursor via $queryRawUnsafe. If you ever need to export a CSV bigger than RAM, this matters a lot.

Why Prisma is slower (the actual reasons)

Forget the "Rust binary Inter-Process Communication (IPC)" narrative — Prisma 7's adapter-pg path is pure JS now. The slowness comes from the emitted SQL itself:

  1. AND $4=$5 no-op tautologies — query engine emits these for null/optional handling. PG plans around them.
  2. Wrapper subqueriesWHERE id <= (SELECT id WHERE id=$1) instead of WHERE id < $1. Extra index lookup.
  3. LIMIT $2 OFFSET $3 on findUnique — prevents PG's unique-index fast path.
  4. title::text LIKE ($2||$3) — explicit cast + concat, kills text_pattern_ops index. This is exactly why delete is 6x slower than the others.
  5. Multi-query loading by defaultrelationLoadStrategy: 'query' causes 3 round-trips for one findUnique with include.

The Rust engine bashing is mostly outdated. The current bottleneck is Prisma's SQL generator emitting safe-but-suboptimal SQL. Drizzle v1 RC proves it can be done better in pure JS — its emitted SQL is consistently the cleanest of the three, and v1 closed most of the perf gap to TypeORM.

Top 5 Prisma performance killers

  1. include: { _count } on hot paths — generates a full GROUP BY of the related table, every request. Replace with batched count.
  2. include without relationLoadStrategy: 'join' — N+1 round-trips disguised as one ORM call.
  3. findUnique everywhere — emits LIMIT $/OFFSET $. Use findFirst unless you need the prisma-side uniqueness check.
  4. Deep select with relations on lists — cartesian buffering blows up heap. Use a separate query per relation when N is large.
  5. cursor: { id } keyset pagination — wraps in a subquery. Use where: { id: { lt: cursor } } directly.

TypeORM caveats too

It still wins this bench on most JOIN paths, but it has real warts:

  1. loadRelationCountAndMap runs a separate query. Fine for one page, but at high RPS the extra round-trip adds up.
  2. Pagination emits SELECT DISTINCT when there's a take + JOIN. PG has to sort to dedupe. Part of why TypeORM's pagination (662ms) is slower than Prisma's (339ms) here.
  3. leftJoinAndSelect cartesian explosion — wide result sets when fan-outs are large. TypeORM is fast at deduping client-side, but the wire/parse cost grows.
  4. complex-filter is 28x slower than Drizzle v1's (60s vs 2.1s). The QueryBuilder produces a query the planner can't optimize as well.
  5. Raw-aggregation 33.9 seconds p95. TypeORM's connection pool seemed to saturate under that scenario in a way Prisma's and Drizzle's didn't — flagged as suspect outlier in caveats.
  6. createQueryBuilder is verbose. Type safety is weaker than Prisma's. Easy to typo a column name and only find out at runtime.
  7. No connection pool tuning by default. You set max but it doesn't expose min, idleTimeoutMillis, connectionTimeoutMillis cleanly. Have to drop to extra: {}.
  8. TypeORM is in maintenance mode. Last meaningful release was December 2025. Roadmap is unclear.

Drizzle caveats too

v1 RC closes most of the gaps that 0.44.x had, but it's not perfect:

  1. deep-nested is still 6.5x slower than TypeORM (855ms vs 132ms). Three-level with: { ... with: { ... with: ... } } chains pay heavy LATERAL materialization cost. v1 helps but doesn't fix this. Benchmark your deepest hot path before committing.
  2. n+1 is 2.3x slower than TypeORM when the relational query API is used. The two-query pattern (one for posts, one for comments via window function) lands at 437ms vs TypeORM's 188ms — Drizzle's filter-object SQL has overhead vs TypeORM's flat raw query.
  3. Raw-aggregation regressed 90% from 0.44.7 to v1 RC. Identical SQL — must be a driver/cursor layer change. Treat as a v1 RC bug worth filing.
  4. Pre-1.0 / RC API churn. v1 is not GA yet. The relational query API just changed significantly between 0.44 and v1 (operators replaced by filter-object syntax, relations() replaced by defineRelations()). Schema migrations between major versions are a real cost.
  5. No .prisma-style schema DSL. Schema lives in TS as pgTable(...) + defineRelations(...). Some teams prefer the single-file Prisma DSL for cross-stack readability.
  6. No automatic relation count. You write the count query yourself every time. (Some folks see this as a feature — no _count cartesian footgun.)
  7. No introspection-driven client. Prisma's prisma generate builds a fully-typed client from your schema; if you change a column name, the client signatures shift and the type-checker tells you everywhere it broke. Drizzle relies on you keeping the schema TS file in sync.
  8. Migrations via drizzle-kit are still maturing. Tooling has gaps (e.g. complex constraint diffing, RLS support). For non-trivial schemas, you still write some migrations by hand.
  9. Smaller ecosystem. Fewer Nest.js integrations, fewer "it just works" examples for niche cases (multi-tenant row security, soft deletes, etc.). You'll hit the docs sooner.
  10. with: { author: true } over-fetches all columns of User. If you want a narrow projection, drop to the query-builder API (db.select({...}).from(...)) — which loses the ergonomic with syntax.

Project health: GitHub at a glance

Numbers as of May 2026:

MetricPrismaTypeORMDrizzle
Stars45,87236,46434,150
Forks2,1826,5111,352
Open issues2,4314171,290
Open pull requests (PRs)139106442
Latest stable release7.8.0 (Apr 22, 2026)0.3.28 (Dec 3, 2025)0.45.2 (Mar 27, 2026)
Latest pre-release1.0.0-rc.1
Last commitApr 29, 2026Apr 27, 2026May 2, 2026

What this tells you about each project's health in 2026:

  • Prisma ships every 1-2 weeks but has the largest issue backlog (~2.4k open). Funded company, active development. Famously slow triage — bugs sit for years.
  • TypeORM has had one minor release in 5+ months. The 6,511 fork count vs Prisma's 2,182 tells you something about how often users patch it themselves. Effectively in maintenance mode.
  • Drizzle has the highest open-PR count (442). Active development, monthly release cadence on the 0.x line, plus the v1 RC train. The numbers in this post used 1.0.0-rc.1 — significantly faster than the 0.45.2 stable for JOIN-heavy work.

The forks-to-stars ratio: TypeORM 18%, Prisma 5%, Drizzle 4%.

The optimization checklist (Prisma without raw SQL)

If you're stuck with Prisma and can't drop to raw queries:

  1. relationLoadStrategy: 'join' on every findUnique/findMany with include.
  2. ✅ Replace _count with a separate batched count query.
  3. findFirst instead of findUnique when you don't need the uniqueness check.
  4. ✅ Direct where: { id: { lt } } instead of cursor: API.
  5. select whitelist instead of include of full objects — cuts payload + heap.
  6. ✅ Composite indexes covering WHERE + ORDER BY: @@index([authorId, createdAt(sort: Desc)]).
  7. ✅ Array form prisma.$transaction([...]) for independent writes — single round-trip.
  8. previewFeatures = ["nativeDistinct", "partialIndexes"] for less app-side work.
  9. ✅ Connection pool sized to actual concurrency: ?connection_limit=N in DATABASE_URL.
  10. ✅ DataLoader-style batching for repeated findUnique calls in a single request.
  11. ✅ Raw SQL via $queryRawUnsafe for hot aggregations and window functions.

Verdict

The simple version: TypeORM is the safest perf bet for relation-heavy work, Drizzle v1 is the fastest on filter-heavy and cursor work plus the lowest memory, Prisma needs patches but ships. The longer version:

Use Prisma if:

  • You want the schema-first DSL and the migration tooling.
  • Your team values the (objectively excellent) developer experience and is willing to pay the perf+memory cost.
  • You're shipping a product where the database is rarely the bottleneck (CRUD-heavy admin tools, low-RPS APIs).
  • You can afford to apply the 4-patch checklist without crying.

Use TypeORM if:

  • You want the fastest result on JOIN-heavy hot paths (won 6/12).
  • You need streaming (qb.stream()) for large exports — only one of the three with first-class support.
  • You're OK with the verbose createQueryBuilder syntax and weaker type-safety.
  • You can live with a project in de facto maintenance mode.

Use Drizzle (v1 RC or wait for GA) if:

  • Your hot paths involve complex WHERE filters with EXISTS / OR / range — Drizzle v1 is dramatically faster (2.1s vs 60s on complex-filter).
  • You want lowest memory footprint (wins 7/12).
  • You like writing TS-first schemas and don't miss .prisma DSL.
  • You're starting a new project in 2026 and can absorb a v1 GA upgrade later.
  • You're OK accepting that 3+-level nested with: { ... } is still slower than a flat TypeORM JOIN. Benchmark your deepest path before committing.

What about Kysely? Not benchmarked here — Kysely is a query builder, not an ORM, so dropping it into the same NestJS controllers would be apples-to-oranges (no with: { ... }, no entity hydration, you write every JOIN by hand). If you're comfortable doing that, Kysely is likely the fastest option of all four because it skips the ORM layer entirely. But the trade-off (no relation magic, no findMany({ where, orderBy }) ergonomics) means it's not interchangeable with the three above.

The interesting takeaway from this whole exercise isn't which ORM "won" — it's that the marketing narrative is mostly wrong. Prisma's slowness comes from the emitted SQL, not the engine. Drizzle 0.44's "elegant SQL" was actually slower than TypeORM's "ugly cartesian"; v1 RC fixes most of that. TypeORM is in maintenance mode but still wins this bench more than the other two on relation-heavy work — except where Drizzle v1 dramatically dethrones it (complex-filter, cursor, delete).

Memory is the harder problem. There's no flag for that. If you're running on a 256 megabyte serverless function and loading lists with relations, Prisma will trigger Out-Of-Memory (OOM) kills. TypeORM and Drizzle will not. Plan accordingly.