Benchmarks

Three-way head-to-head against Prisma 7.9 (@prisma/adapter-pg, relationJoins preview on) and Drizzle 0.45 (node-postgres relational API) on a local PostgreSQL 17.9 database reached over a Unix socket, plus a hand-written pg control arm so you can read ORM overhead directly instead of inferring it.

The ten scenarios below were measured on 2026-08-09 against turbine-orm 0.66.0; the extended suite and the latency sweep were measured against 0.67.0. The current release is 0.67.0. Nothing below has been adjusted to match anything, because quoting a stale measurement is honest and inventing a fresh-looking one is not. Read the table as the shape of the result, who leads which scenario and by roughly how much, rather than as the latency you will see. Every arm ran once per round, the arm order rotated every round, and each figure is the median across 200 rounds, taken as the median of three full runs. See Methodology for why that matters, and Reproduce to re-run it against the version you are actually installing.

TL;DR. Over a local socket there is no network round-trip, so per-query overhead is the entire signal and most numbers are sub-millisecond. The single most useful figure on this page: Turbine runs at 1.09x hand-written pg, where Drizzle runs at 1.49x and Prisma at 1.86x (geometric mean over the eight scenarios with a raw control). Across all ten scenarios Turbine is 1.82x faster than Prisma 7.9 and 1.32x faster than Drizzle 0.45 by geometric mean. Turbine takes seven scenarios, Drizzle takes three (L2 nested, L3 nested, and streaming, which Drizzle wins outright by 24%), and Prisma takes none. Two of the ten are contested between the two harnesses and are claimed by neither side.

Those ten are also a narrow set, and the extended suite says so: three of them are findUnique, the largest non-stream result is 100 rows, and nothing measures a write beyond one atomic increment. Across all 24 shapes Turbine leads 17. The additions found a loss the ten could not see (deep pagination, where a named prepared statement hits PostgreSQL's generic-plan cliff) and overturned a recommendation they would have produced (relationLoadStrategy: 'batched' is fastest on a socket and slower by 1 ms of round-trip time). Read Latency before acting on any single number here.

The one we lose, stated plainly#

Drizzle beats Turbine on streaming by 24%: 40.81 ms against Turbine's 54.01 ms to drain 50,000 comments in batches of 1,000. This reproduced in every run across both harnesses, so it is not noise.

An earlier version of this page showed Turbine fastest at streaming (60.7 ms) and called the scenario a near-tie. That was wrong. It rested on a single Drizzle measurement of 65.8 ms which we have not been able to reproduce: Drizzle measured 38.9 to 48.3 ms across the runs here. The tell is the control arm. Hand-written keyset pagination in raw pg drains the same table in 42.77 ms, and Drizzle sits directly on top of it, which is where a thin query builder doing a keyset loop should sit. The old 65.8 ms figure was the outlier, and the near-tie label was an artifact of it.

The honest reading is that Turbine's cursor carries roughly 25% overhead above hand-written keyset pagination on a full-table drain, and Drizzle's carries approximately none.

What a single number does not capture, and what you are buying for that 25%:

  1. Correctness without a monotonic key. Keyset pagination requires orderBy on a unique column. Turbine's cursor works with any orderBy.
  2. Clean early break. for await ... break closes server-side state and releases the connection deterministically. This scenario drains the whole table, which is the case that flatters keyset most.
  3. Nested with inside the stream. Cursor batches resolve a full with clause per batch.

Those are real, but they are tradeoffs, not a rebuttal. If you are draining a large table on a unique ascending key and nothing else, Drizzle is measurably faster at it today.

Methodology, and why the harness changed#

The previous numbers came from bench.ts, which measures each ORM in a contiguous block: 200 Turbine iterations, then 200 Prisma, then 200 Drizzle. Anything that drifts over the life of the process (JIT state, page cache, autovacuum, thermal headroom) gets charged to whichever arm happened to occupy that slice of wall clock, and there was no control arm to tell drift from signal.

bench-interleaved.ts runs every arm once per round, rotates the arm order every round, reports the median across rounds, and adds a hand-written raw pg control to most scenarios. Both harnesses were run: the old one twice, because it is the only apples-to-apples way to diff against the earlier table, and the new one three times, because those are the numbers published here. The two harnesses agree on every winner except atomic increment, which is reported as the near-tie it is.

Setup#

  • Database. Local PostgreSQL 17.9 (Homebrew), Unix-socket connection, no network hop, dedicated freshly seeded database. Deliberately not a container: Docker on macOS routes through a VM and a forwarded TCP port, which adds a latency floor.
  • Client. Apple Silicon MacBook Pro (Apple M5 Max), macOS 26.5.1, Node v24.18.0. Client and database are the same host.
  • Schema. organizations / users / posts / comments.
  • Data. 5 orgs, 1,000 users, 10,000 posts, 50,000 comments, deterministic and identical for every ORM, ANALYZEd after load. Row counts are verified before any number is recorded and the harness refuses to report if they do not match.
  • Versions. turbine-orm 0.66.0, @prisma/client 7.9.0 + @prisma/adapter-pg 7.9.0, drizzle-orm 0.45.2, pg 8.22.0. The competitor versions are deliberately unchanged from the 0.50.0 run, which is what makes the two comparable, but they are pinned rather than current: all three have shipped releases since they were last checked, so re-check before quoting these ratios anywhere that matters.
  • Runs. 200 rounds + 20 warmup per arm per scenario (streaming: 9 rounds + 1 warmup, each round drains 50K rows). Three full runs; the table is the median of the three medians.
  • Pool. Plain pg.Pool size 10 for Prisma, Drizzle and the raw control. Turbine uses its internal pool at default size.
  • Relation strategy. Turbine's default is 'auto' (since 0.41). It does not engage anywhere in this suite: every scenario uses a limit of 50 or less, every relation used is to-many, and every FK in the fixture is indexed, so the plan is the byte-identical single-statement join on every scenario. Verified by statement counting.

The drift floor: read this before the table#

The identical raw pg control arm was run inside every scenario that has one, in all three runs. Its spread across those runs is the floor below which nothing on this page is a measurement.

Scenarioraw control, 3 runs (ms)spread
findMany flat0.210 / 0.207 / 0.2122.4%
findMany L21.939 / 1.958 / 1.9531.0%
findUnique PK0.053 / 0.060 / 0.06420.8%
count0.044 / 0.045 / 0.0476.8%
stream 50K46.59 / 50.97 / 53.2214.2%
atomic increment0.073 / 0.107 / 0.09546.6%
pipeline0.201 / 0.219 / 0.2059.0%
hot findUnique0.033 / 0.043 / 0.03330.3%

Same SQL, same data, same process, no code change. On the multi-millisecond scenarios the floor is 1% to 14%. On the sub-0.15 ms scenarios it is 21% to 47%.

So every sub-0.15 ms figure below carries roughly one third uncertainty in its absolute value. That covers findUnique by PK, count, atomic increment, hot findUnique and the pipeline batch. The orderings on those rows are stable across all five runs and can be relied on. The millisecond values cannot be compared to another run's millisecond values at that resolution, in either direction, and any "regression" or "improvement" smaller than a third on those rows is not real.

Raw results, measured 2026-08-09#

Median wall-clock ms per operation (lower is better). Bold = fastest ORM. The raw pg column is the hand-written control.

ScenarioTurbine 0.66Prisma 7.9Drizzle 0.45raw pg
findMany, 100 users (flat)0.189 ms0.254 ms0.248 ms0.164 ms
findMany, 50 users + posts (L2) (contested)2.379 ms4.289 ms1.804 ms1.779 ms
findMany, 10 users → posts → comments (L3)1.436 ms3.990 ms1.223 msn/a
findUnique, single user by PK0.038 ms0.083 ms0.085 ms0.037 ms
findUnique, user + posts + comments (L3)0.197 ms0.397 ms0.303 msn/a
count, all users0.043 ms0.076 ms0.058 ms0.044 ms
stream, iterate 50K comments (batch 1000)54.01 ms57.93 ms40.81 ms42.77 ms
atomic increment, view_count + 1 (contested)0.073 ms0.114 ms0.079 ms0.060 ms
pipeline, 5-query dashboard batch0.183 ms0.386 ms0.366 ms0.194 ms
hot findUnique, 500x same shape0.029 ms0.064 ms0.073 ms0.033 ms

Ratio vs fastest#

1.00x = fastest ORM on that scenario. Higher = slower.

ScenarioTurbine 0.66Prisma 7.9Drizzle 0.45
findMany, flat1.00x1.34x1.31x
findMany, L2 (contested)1.32x2.38x1.00x
findMany, L31.17x3.26x1.00x
findUnique, PK1.00x2.18x2.24x
findUnique, L31.00x2.02x1.54x
count1.00x1.77x1.35x
stream, 50K1.32x1.42x1.00x
atomic increment (contested)1.00x1.56x1.08x
pipeline, 5-query batch1.00x2.11x2.00x
hot findUnique1.00x2.21x2.52x

Scenario wins: Turbine seven, Drizzle three, Prisma none. Prisma is slower than Turbine on all ten.

Two of the ten are contested and should not be read as leads by either side. This run used two independent harnesses and they disagree about L2 nested (interleaved: Drizzle 1.804 vs Turbine 2.379; contiguous: Turbine 1.34 vs Drizzle 2.03) and atomic increment (interleaved: Turbine 0.073 vs Drizzle 0.079; contiguous: Drizzle fastest, Turbine 1.50x). The tables above report the interleaved harness because it rotates arm order every round and so shares drift across arms instead of letting it land on one. Where the two designs disagree, the scenario is close enough that the design decides it, and the honest answer is a tie. The 0.50.0 run recorded this for one scenario; it is now two. Streaming is not one of them: Drizzle wins it by 24% in every run of both harnesses.

Overhead above hand-written pg#

Geometric mean over the eight scenarios with a raw control:

overhead vs raw pg
Turbine 0.661.09x
Drizzle 0.451.49x
Prisma 7.91.86x

This is the most defensible number on the page, and the one worth quoting. The control is measured in the same rounds as the ORMs, so it cancels most of the drift; it has an absolute floor (nothing can beat 1.00x); and unlike a competitor ratio it does not move when a competitor ships a release. Read it as: on these shapes, choosing Turbine over writing the SQL yourself costs about 9% of the query time.

Across all ten scenarios, geometric mean: Turbine is 1.82x faster than Prisma 7.9 and 1.32x faster than Drizzle 0.45.

What the numbers actually mean#

1. On a local socket, the ORM layer is the whole story#

With the network removed, the fastest single SELECT (findUnique by primary key) runs in ~0.05 ms, and the gap between ORMs is the gap in their JavaScript per-query work rather than measurement noise. A pooled remote database compresses all of this back toward the network floor.

Conclusion. A socket-local benchmark measures which layer is leanest. A pooled-remote benchmark measures whether that matters in production (usually only a little). Read both, and read them separately.

2. Turbine leads the simple and repeated-shape reads#

Flat findMany, both findUnique shapes, count, the 5-query pipeline and the hot 500x-same-shape loop all go to Turbine. Shape-keyed SQL template caching means a repeated query skips SQL generation entirely, and prepared statements let Postgres reuse the plan. On the hot path Turbine runs at ~34,500 ops/sec versus ~15,400 for Prisma and ~13,300 for Drizzle.

3. Drizzle leads both nested reads and streaming#

Drizzle's relational query builder leads L2 on the primary harness (1.804 ms vs Turbine's 2.379 ms) and leads L3 (1.223 ms vs 1.436 ms). L2 is contested: the contiguous harness reverses it, so neither side owns that scenario (see above). Drizzle also wins streaming outright, by 24% (see above). Turbine's json_agg nesting sits behind Drizzle and 1.8x to 2.8x ahead of Prisma on the nested shapes (1.80x on L2, 2.78x on L3, 2.02x on the nested findUnique).

The gap itself has since been root-caused, and it is one default. Splitting the L2 wall time into server, wire and client shows the plan shape is a wash: with the JSON encoding held equal, server time is 0.424 ms for Turbine's correlated subquery against 0.363 ms for Drizzle's LEFT JOIN LATERAL, and our client-side decode is faster than Drizzle's while decoding more bytes. The whole difference is that json_build_object writes every key name into every nested object of every row: 34% of the L2 payload is key strings, 153.8 KB against 102.1 KB.

Turbine already ships the encoding that removes them. jsonEncoding: 'positional' emits the same json_build_array shape Drizzle uses, and with it turned on Turbine wins both nested scenarios: 1.615 ms against Drizzle's 1.690 on L2, and 0.506 against 0.629 on L3. It is not yet the default, and deliberately so: positional decoding maps array positions back to field names from a build-time shape, which turns any drift into a silent wrong-field bug rather than a loud error, so it wants the differential fuzz treatment before it becomes what everyone gets. That is the planned path rather than a promise of a date.

One thing remains genuinely unexplained, and it is narrower than the sentence that used to sit here: the widening between runs. L2 was published at 1.21x in the 0.50.0 run and measures 1.32x here. It is not the 0.66.0 release, since a direct interleaved A/B of 0.65.0 against 0.66.0 on the same database found 0.66.0 equal or marginally faster on every shape. Turbine's absolute L2 number barely moved; the raw control and Drizzle each got about 10% faster and Turbine did not.

4. Pipeline batching is Turbine's clearest win#

The 5-query dashboard batch runs in 0.206 ms on Turbine versus 0.431 ms (Prisma) and 0.402 ms (Drizzle): 2.09x and 1.95x faster respectively, and level with the raw pg control at 0.205 ms. Turbine sends all five queries in a single TCP flush using the Postgres extended-query pipeline protocol; Prisma's $transaction([]) and Drizzle's db.transaction() run their queries sequentially, waiting for each reply before sending the next. On a local socket that serialization cost is small in absolute terms; on a high-latency connection it grows with every round-trip.

5. Prisma trails everywhere on a local socket#

Prisma 7 dropped its Rust engine, but the client still does meaningful per-query work in TypeScript. With no network to hide it, that overhead puts Prisma behind Turbine on all ten scenarios, at 1.84x hand-written pg. This is a socket-local finding, not a claim about production: over a pooled remote network the same Prisma queries land inside the noise floor.

Beyond the headline ten#

Measured against 0.67.0 on the same machine, same fixture and same interleaved harness. The ten scenarios above are skewed and it is worth saying how: three of them are findUnique, the largest non-stream result is 100 rows, nothing measures a write other than one atomic increment, and nothing measures aggregation, to-one relations, many-to-many or relation filters. That set flatters small hot reads, which is not where applications spend their time.

The suite now covers 24 shapes. Turbine is fastest on 17 of them. The additions changed the picture in both directions: they found a loss the old set could not see, and they overturned a recommendation the old set would have produced.

Large result sets: the thing the old suite was hiding#

At 100 rows the row decoder is invisible. At scale it dominates.

ScenarioTurbine 0.67Prisma 7.9Drizzle 0.45raw pg
findMany, 5,000 comments4.462 ms (1.07x raw)5.156 (1.24x)4.837 (1.16x)4.174
findMany, 50,000 comments44.751 ms (1.07x raw)55.451 (1.33x)50.391 (1.20x)41.829
findMany, 1,000 rows x 39 columns4.551 ms (1.21x raw)6.087 (1.61x)6.118 (1.62x)3.772

Turbine runs within 7% of hand-written pg on a 50,000-row read where the others are 20% and 33% above it. The wide-table row isolates per-column cost specifically, on a deliberately 39-column table, and both competitors land at ~1.6x raw against Turbine's 1.21x.

Writes#

ScenarioTurbine 0.67Prisma 7.9Drizzle 0.45raw pg
createMany, 1,000 rows6.780 ms (1.38x raw)39.530 (8.06x)17.031 (3.47x)4.903
update by primary key0.094 ms0.1410.1030.081
upsert on a unique column0.105 ms0.1400.1130.082
delete by unique column0.084 ms0.1230.0860.068

Bulk insert is the widest margin in the whole suite: 5.8x Prisma and 2.5x Drizzle, and Turbine is the only arm within 2x of a hand-written UNNEST insert. The three single-row rows are honest ties between Turbine and Drizzle: the differences are 0.009, 0.008 and 0.002 ms against a 0.018 ms drift floor for that run. Both are clearly ahead of Prisma.

Relations the headline set never touched#

ScenarioTurbine 0.67Prisma 7.9Drizzle 0.45
to-one, 500 posts + author1.419 ms1.5211.485
many-to-many, 200 posts + tags2.341 ms4.9832.902
relation _count, 100 users0.240 ms0.2990.357
relation filter (some), 100 users0.200 ms0.2680.252
groupBy + having, 5 groups0.701 ms0.7560.720

Many-to-many is the widest non-write margin, and it is also the one place the result shapes differ, which is the finding rather than a caveat: Turbine derives the junction from its two foreign keys and returns post.tags[], while Prisma and Drizzle both need the junction as an explicit model or table here and return post.postTags[].tag, one level deeper, for the caller to flatten. Each arm is written the way that ORM's own docs prescribe. The _count and relation-filter rows carry the same kind of note in the harness output: Drizzle's relational API has neither primitive, so those arms are hand-written subqueries through its query builder.

On groupBy all three sit within a few percent of raw pg, and the Turbine-to-Drizzle difference is itself at the drift floor. Read that row as "no ORM has a problem here" rather than as a win.

A loss we can now name: deep pagination#

ScenarioTurbine 0.67Prisma 7.9Drizzle 0.45raw pg
filtered, sorted 20-row page, offset 400.212 ms0.2380.2580.186
the same query at offset 9,0000.549 ms0.3180.3410.276
...with forceCustomPlan: true0.292 ms

Turbine names its prepared statements, which is exactly what wins the hot repeated-shape reads above, because node-postgres skips Parse only for a statement already parsed by name. PostgreSQL promotes a named statement to a value-blind generic plan on its sixth execution, and with OFFSET bound as a parameter that generic plan cannot know the offset is 9,000.

This is a property of naming statements, not of Turbine, and the control arm settles it: the same hand-written pg query costs 0.541 ms named and 0.272 ms unnamed. Drizzle and Prisma escape the cliff only because they never name statements, which is the same reason they never get the Parse skip. If you paginate deeply on a hot endpoint, set forceCustomPlan: true on that query and you land within 6% of raw.

Latency: the axis a socket cannot show#

Every number above this line is measured over a Unix socket where a round trip is ~0.05 ms. That is not a footnote, because it changes the sign of some results. These were measured through an in-process TCP proxy that forwards to the same PostgreSQL socket and delays each chunk by RTT/2. It models latency, not bandwidth or jitter, and the 0 ms row runs through the proxy too so every comparison is like for like.

Dashboard, 5 independent queries (medians, ms)

arm0 ms1 ms5 ms25 ms
Turbine pipeline0.3953.0595.58326.830
Prisma 7 $transaction1.13318.34635.169183.841
Drizzle transaction1.05318.34934.823184.030

Turbine's pipeline advantage grows from 2.7x to 6.9x as the link lengthens, because it is one round trip against roughly seven (BEGIN, five queries, COMMIT). Neither competitor has independent-query pipelining. This is the clearest case on the page where a socket-local benchmark understates a real difference, and it understated it by 2.5x.

Nested read, 50 users + posts (medians, ms)

arm0 ms1 ms5 ms25 ms
Turbine join2.5275.1157.80929.487
Turbine batched1.4076.80711.34354.003
Drizzle1.8974.6207.25827.856

relationLoadStrategy: 'batched' inverts. On a socket it is the fastest strategy by 1.34x, which is exactly the recommendation a socket-only suite would have produced. By 1 ms of round-trip time it is already slower than the join, and at 25 ms it is 1.83x slower, because it buys its win with an extra round trip per relation. Turbine's default 'auto' staying on the join is the right call for anything on a real link, and if your deployment is not on a socket the thing to set is autoRoundTripMs, not a pinned strategy.

Point reads converge. At 25 ms every findUnique arm lands within 0.3 ms of every other and of raw pg (Turbine 26.509, Drizzle 26.753, Prisma 26.828, raw 26.526). Turbine's ~1.5x advantage on hot small reads is real and worth having, but it is a low-latency-link result, and publishing it without this row overstates it.

What this cannot show. The proxy models latency, not bandwidth. The 34% payload reduction jsonEncoding: 'positional' buys does not appear in these tables at all and would only show up on a bandwidth-constrained link, so the case for that change is stronger than anything here can demonstrate.

Caveats#

  • These are socket-local numbers. They isolate per-query overhead and deliberately exclude the network that usually dominates production latency. For a networked, pooled database the deltas here shrink toward the noise floor.
  • Read the drift floor before quoting any sub-0.15 ms figure. Those absolute values carry roughly a third uncertainty. Their orderings are stable; their millisecond values are not precise.
  • All measurements are wall-clock from the client, including query execution and result serialization.
  • Single machine. Client and database are the same host, so there is no round-trip and no pooler hop.
  • Prisma's nested scenarios run on its join strategy, deliberately. benchmarks/prisma/schema.prisma enables the relationJoins preview feature, which per Prisma's docs makes join the client-wide default, and the harness never overrides relationLoadStrategy on a query. Without the flag Prisma would use its query strategy and lose the nested scenarios by a wider margin. We measured Prisma in its favorable configuration.
  • We did not test connection pool starvation, long transactions, or write-heavy workloads. Those are different benchmarks.
  • The streaming scenario drains the full table. A scenario that breaks out after the first match would show cursor cleanup advantages that a single number cannot capture. It is also the shape that most flatters keyset pagination.
  • Prisma 7.6 was not installed or measured in this run. An earlier version of this page quoted 7.6-to-7.9 improvement percentages; they are not reproducible from this data and have been removed rather than carried forward.
  • NODE_ENV is unset, which leaves Turbine's dev-mode cache cross-check guards armed. A control run with NODE_ENV=production produced Turbine numbers indistinguishable on every scenario, so the published figures are not depressed by those guards.
  • The headline ten were measured against 0.66.0; the extended suite and the latency tables against 0.67.0. They are separate runs on the same machine and fixture, not one table split in two, so compare within a section rather than across them.
  • The many-to-many arms do not return the same shape. Turbine returns post.tags[]; Prisma and Drizzle return post.postTags[].tag, one level deeper. Each arm is written the way that ORM's own documentation prescribes, and the difference is reported as a finding rather than normalized away.
  • Drizzle has no relation _count and no relation-filter primitive, so those two arms are hand-written subqueries through its query builder. A different hand-written formulation would move that number.
  • The latency proxy models latency, not bandwidth or jitter. It delays each TCP chunk by RTT/2 and forwards to the same local socket. Payload size therefore costs nothing in those tables, which understates the case for jsonEncoding: 'positional' and for any strategy that moves fewer bytes.

Reproduce#

Raw data, the full writeup and all three harnesses are committed to the repo:

createdb turbine_bench
cd benchmarks
npm install && npx prisma generate
DATABASE_URL="postgresql:///turbine_bench?host=/tmp" npx tsx seed-neon.ts
DATABASE_URL="postgresql:///turbine_bench?host=/tmp" npx tsx bench-interleaved.ts
DATABASE_URL="postgresql:///turbine_bench?host=/tmp" npx tsx bench-extended.ts
DATABASE_URL="postgresql:///turbine_bench?host=/tmp" npx tsx bench-latency.ts

Any Postgres endpoint works: Neon, Vercel Postgres, Supabase, or a local socket. Expect the shape above on a local socket and a flatter, network-dominated table on a pooled remote database.