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.
Measured 2026-08-15 against turbine-orm 0.71.0; the extended suite and the latency sweep were measured against 0.67.0, and the current release is 0.79.1. Environment: Apple M5 Max, macOS 26.5.1, Node v24.18.0, PostgreSQL 17.9 over a Unix socket, client and database on one host. Each figure is the median across 200 rounds with arm order rotated every round, taken as the median of three full runs (Methodology). Full raw data:
benchmarks/RESULTS-0.71.0.md. To measure the version you are installing, see Reproduce.Noise floor: every sub-0.15 ms figure carries roughly one third uncertainty in its absolute value; the orderings are stable across runs. Per-scenario spreads are in the drift floor.
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.08x hand-written
pg, where Drizzle runs at 1.47x and Prisma at 1.81x (geometric mean over the eight scenarios with a raw control). Across all ten scenarios Turbine is 2.02x faster than Prisma 7.9 and 1.46x faster than Drizzle 0.45 by geometric mean. Turbine took nine of the ten here and Prisma took none; the one Drizzle took was streaming, and two later re-measurements (0.72.0 and 0.76.0) both reverse it against the published Drizzle 0.45.2. Three of the ten are contested and are claimed by neither side.The extended suite widens the set to 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 lost, and how it was closed#
On 0.71.0, Drizzle beat Turbine on streaming by 22%: 39.49 ms against Turbine's 50.39 ms to drain 50,000 comments in batches of 1,000. That reproduced in every run across both harnesses, so it was not noise. The rest of this section is how it was tracked down, kept because the diagnosis took three releases and the wrong answer was obvious twice.
The control arm locates the gap. Hand-written keyset pagination in raw pg drains the same table in 42.33 ms, and Drizzle sits directly on top of it, which is where a thin query builder doing a keyset loop should sit. Turbine's cursor carries roughly 19% overhead above hand-written keyset pagination on a full-table drain, and Drizzle's carries approximately none.
What the 19% buys:
- Correctness without a monotonic key. Keyset pagination requires
orderByon a unique column. Turbine's cursor works with anyorderBy. - Clean early break.
for await ... breakcloses server-side state and releases the connection deterministically. This scenario drains the whole table, which is the case that flatters keyset most. - Nested
withinside the stream. Cursor batches resolve a fullwithclause per batch.
If you are draining a large table on a unique ascending key and nothing else, keyset pagination is the leaner shape and findManyStream is the arm that still pays for the difference. findManyStreamBatches gives up the per-row API and takes the scenario back against Drizzle 0.45.2; against the 1.0 release candidate it draws level rather than passing it.
Methodology, and why the harness changed#
Earlier numbers came from bench.ts, which measured 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 occupied 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 contiguous one twice (the only apples-to-apples way to diff against the earlier table), the interleaved one three times (the numbers published here). The two designs disagree on three scenarios, L2 nested, L3 nested and atomic increment, reported below as contested.
Setup#
- Database. Local PostgreSQL 17.9 (Homebrew), Unix-socket connection, no network hop, dedicated freshly seeded database. 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.71.0,
@prisma/client7.9.1 +@prisma/adapter-pg7.9.1,drizzle-orm0.45.2,pg8.22.0. Checked on the measurement date: Prisma and Drizzle had both shipped nothing newer.pghad moved to 8.23.0 and was deliberately left at 8.22.0, because it is the raw control and also underlies all three ORM arms, so bumping it would move every arm at once. - 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.Poolsize 10 for Prisma, Drizzle and the raw control. Turbine uses its internal pool at default size. - Relation strategy. Turbine's default
'auto'(since 0.41) 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 ran 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.
| Scenario | raw control, 3 runs (ms) | spread |
|---|---|---|
| findMany flat | 0.165 / 0.164 / 0.150 | 9.5% |
| findMany L2 | 1.843 / 1.847 / 1.965 | 6.6% |
| findUnique PK | 0.034 / 0.036 / 0.037 | 9.0% |
| count | 0.041 / 0.042 / 0.041 | 3.5% |
| stream 50K | 43.41 / 42.33 / 41.87 | 3.7% |
| atomic increment | 0.078 / 0.051 / 0.055 | 54.3% |
| pipeline | 0.213 / 0.176 / 0.177 | 20.9% |
| hot findUnique | 0.036 / 0.029 / 0.030 | 25.7% |
Same SQL, same data, same process, no code change. On the multi-millisecond scenarios the floor is 4% to 7%; on the sub-0.15 ms scenarios it reaches 54%. So every sub-0.15 ms figure below (findUnique by PK, count, atomic increment, hot findUnique, the pipeline batch) carries roughly one third uncertainty in its absolute value. The orderings on those rows are stable across all runs; the millisecond values are not comparable to another run's at that resolution.
Raw results, measured 2026-08-15#
Median wall-clock ms per operation (lower is better). Bold = fastest ORM. The raw pg column is the hand-written control.
| Scenario | Turbine 0.71 | Prisma 7.9 | Drizzle 0.45 | raw pg |
|---|---|---|---|---|
| findMany, 100 users (flat) | 0.181 ms | 0.241 ms | 0.242 ms | 0.164 ms |
| findMany, 50 users + posts (L2) (contested) | 1.670 ms | 4.302 ms | 1.711 ms | 1.847 ms |
| findMany, 10 users → posts → comments (L3) (contested) | 1.082 ms | 4.204 ms | 1.166 ms | n/a |
| findUnique, single user by PK | 0.035 ms | 0.078 ms | 0.080 ms | 0.036 ms |
| findUnique, user + posts + comments (L3) | 0.146 ms | 0.374 ms | 0.285 ms | n/a |
| count, all users | 0.038 ms | 0.070 ms | 0.053 ms | 0.041 ms |
| stream, iterate 50K comments (batch 1000) | 50.39 ms | 54.81 ms | 39.49 ms | 42.33 ms |
atomic increment, view_count + 1 (contested) | 0.067 ms | 0.106 ms | 0.073 ms | 0.055 ms |
| pipeline, 5-query dashboard batch | 0.165 ms | 0.338 ms | 0.341 ms | 0.177 ms |
| hot findUnique, 500x same shape | 0.026 ms | 0.059 ms | 0.069 ms | 0.030 ms |
Ratio vs fastest#
1.00x = fastest ORM on that scenario. Higher = slower.
| Scenario | Turbine 0.71 | Prisma 7.9 | Drizzle 0.45 |
|---|---|---|---|
| findMany, flat | 1.00x | 1.34x | 1.34x |
| findMany, L2 (contested) | 1.00x | 2.58x | 1.02x |
| findMany, L3 (contested) | 1.00x | 3.88x | 1.08x |
| findUnique, PK | 1.00x | 2.25x | 2.30x |
| findUnique, L3 | 1.00x | 2.56x | 1.95x |
| count | 1.00x | 1.84x | 1.41x |
| stream, 50K | 1.28x | 1.39x | 1.00x |
| atomic increment (contested) | 1.00x | 1.60x | 1.10x |
| pipeline, 5-query batch | 1.00x | 2.04x | 2.07x |
| hot findUnique | 1.00x | 2.28x | 2.68x |
Scenario wins: Turbine nine, Drizzle one, Prisma none. Prisma is slower than Turbine on all ten.
Both tables are 0.71.0 data, as measured on 2026-08-15. The stream row is the one since superseded: 0.72.0 takes it, and the figures are above. It is left standing here rather than patched in because that would mix two harnesses on two dates in one table, and the rest of these rows have not been re-run.
Three of the ten are contested and should not be read as leads by either side. The interleaved harness gives Turbine L2 nested (1.670 vs 1.711) and L3 nested (1.082 vs 1.166), but the contiguous harness will not confirm either: run twice on the identical configuration it reversed itself on both, giving L2 to Drizzle once (2.18 vs 1.63) and to Turbine once (1.54 vs 1.61). Atomic increment is the third, a tie under both designs (interleaved: Turbine 0.067 vs Drizzle 0.073; contiguous: 0.09 vs 0.09). The tables above report the interleaved harness because it rotates arm order every round, sharing drift across arms instead of letting it land on one. Streaming was not contested in this run either, and Drizzle took it by 22% in every run of both harnesses. It has since been re-measured twice and reversed both times; see above.
Overhead above hand-written pg#
Geometric mean over the eight scenarios with a raw control:
overhead vs raw pg | |
|---|---|
| Turbine 0.71 | 1.08x |
| Drizzle 0.45 | 1.47x |
| Prisma 7.9 | 1.81x |
This is the number to quote. The control runs in the same rounds as the ORMs, so it cancels most of the drift, and 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 8% of the query time.
One adjustment is baked into that 1.08x, and it goes against Turbine. The raw control's hand-written L2 statement builds its nested rows with json_build_object, which is what Turbine emitted when the control was written; Turbine now emits json_build_array. Measured on identical rows, that one difference is worth 1.83x (0.801 ms against 0.437 ms), so the control is doing more work than the ORM it is meant to bound and the unadjusted figure comes out at 1.00x, i.e. apparently faster than hand-written SQL. That reading is not supportable, so the table above prices L2 against an encoding-matched control instead. The recorded control arm is deliberately left alone, because rewriting it would break comparability with every earlier published run.
Across all ten scenarios, geometric mean: Turbine is 2.02x faster than Prisma 7.9 and 1.46x 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.035 ms, and the gap between ORMs is the gap in their JavaScript per-query work. A pooled remote database compresses all of this back toward the network floor. 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, 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 ~38,500 ops/sec versus ~16,900 for Prisma and ~14,500 for Drizzle.
3. The nested reads stopped being losses, narrowly#
Both nested findMany shapes were Drizzle's in the previous run and are Turbine's here: L2 1.670 ms against 1.711, L3 1.082 against 1.166. Turbine leads Prisma on the nested shapes by 2.6x on L2, 3.9x on L3 and 2.6x on the nested findUnique. Streaming was Drizzle's by 22% in this run; it is the one row here that later re-measurements overturn (see above).
Both are marked contested, and the mark is doing real work. L2's margin is 2.5%, which is inside what this page tells you not to quote, and the contiguous cross-check harness cannot arbitrate: two runs of the identical configuration reversed each other on both scenarios (L2 gave 2.18 ms against Drizzle's 1.63 in one run and 1.54 against 1.61 in the next). Read L2 and L3 as ties that Turbine now enters level, not as leads.
What changed is one default. json_build_object writes every key name into every nested object of every row, which costs PostgreSQL CPU to emit and 55% more bytes to ship. jsonEncoding: 'positional' emits json_build_array instead, and it is the PostgreSQL default as of 0.71. Forcing the old encoding on the same tree, on the same machine, reproduces the previous run almost exactly: 1.079x geometric mean and 7 of 10 scenarios, against 1.078x and 7 of 10 then. Turning it on takes those to 1.025x and 9 of 10. The improvement shows up on the contiguous harness too, at the same magnitude (L2 2.38 ms to 1.54 ms), which is why the direction is not in doubt even though the winner is.
Rows are identical between the encodings, verified by JSON.stringify equality across five nested shapes including key order. That property is what the encoding had to earn before it could become the default: positional decoding maps array positions back to field names from a build-time shape, so its failure mode is a silently transposed field rather than a loud error, and it is held by the differential fuzz suite rather than by this page.
4. Pipeline batching is Turbine's clearest win#
The 5-query dashboard batch runs in 0.165 ms on Turbine versus 0.338 ms (Prisma) and 0.341 ms (Drizzle): 2.04x and 2.07x faster respectively, and level with the raw pg control at 0.177 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; 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.81x 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, fixture and interleaved harness. The headline ten skew toward small hot reads: three are findUnique, the largest non-stream result is 100 rows, the only write is one atomic increment, and nothing measures aggregation, to-one relations, many-to-many or relation filters. The extended suite covers 24 shapes; Turbine is fastest on 17. The additions found a loss the old set could not see, and overturned a recommendation it 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.
| Scenario | Turbine 0.67 | Prisma 7.9 | Drizzle 0.45 | raw pg |
|---|---|---|---|---|
| findMany, 5,000 comments | 4.462 ms (1.07x raw) | 5.156 (1.24x) | 4.837 (1.16x) | 4.174 |
| findMany, 50,000 comments | 44.751 ms (1.07x raw) | 55.451 (1.33x) | 50.391 (1.20x) | 41.829 |
| findMany, 1,000 rows x 39 columns | 4.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 on a deliberately 39-column table: both competitors land at ~1.6x raw against Turbine's 1.21x.
Writes#
| Scenario | Turbine 0.67 | Prisma 7.9 | Drizzle 0.45 | raw pg |
|---|---|---|---|---|
| createMany, 1,000 rows | 6.780 ms (1.38x raw) | 39.530 (8.06x) | 17.031 (3.47x) | 4.903 |
| update by primary key | 0.094 ms | 0.141 | 0.103 | 0.081 |
| upsert on a unique column | 0.105 ms | 0.140 | 0.113 | 0.082 |
| delete by unique column | 0.084 ms | 0.123 | 0.086 | 0.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 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#
| Scenario | Turbine 0.67 | Prisma 7.9 | Drizzle 0.45 |
|---|---|---|---|
| to-one, 500 posts + author | 1.419 ms | 1.521 | 1.485 |
| many-to-many, 200 posts + tags | 2.341 ms | 4.983 | 2.902 |
relation _count, 100 users | 0.240 ms | 0.299 | 0.357 |
relation filter (some), 100 users | 0.200 ms | 0.268 | 0.252 |
groupBy + having, 5 groups | 0.701 ms | 0.756 | 0.720 |
Many-to-many is the widest non-write margin, and the one place the result shapes differ: 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. Drizzle's relational API 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 those numbers.
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#
| Scenario | Turbine 0.67 | Prisma 7.9 | Drizzle 0.45 | raw pg |
|---|---|---|---|---|
| filtered, sorted 20-row page, offset 40 | 0.212 ms | 0.238 | 0.258 | 0.186 |
| the same query at offset 9,000 | 0.549 ms | 0.318 | 0.341 | 0.276 |
...with forceCustomPlan: true | 0.292 ms |
Turbine names its prepared statements, which is exactly what wins the hot repeated-shape reads above: 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, which changes the sign of some results. These tables 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)
| arm | 0 ms | 1 ms | 5 ms | 25 ms |
|---|---|---|---|---|
Turbine pipeline | 0.395 | 3.059 | 5.583 | 26.830 |
Prisma 7 $transaction | 1.133 | 18.346 | 35.169 | 183.841 |
| Drizzle transaction | 1.053 | 18.349 | 34.823 | 184.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 where a socket-local benchmark understates a real difference, and it understated it by 2.5x.
Nested read, 50 users + posts (medians, ms)
| arm | 0 ms | 1 ms | 5 ms | 25 ms |
|---|---|---|---|---|
Turbine join | 2.527 | 5.115 | 7.809 | 29.487 |
Turbine batched | 1.407 | 6.807 | 11.343 | 54.003 |
| Drizzle | 1.897 | 4.620 | 7.258 | 27.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' stays on the join, which is the right call for anything on a real link; if your deployment is not on a socket, set autoRoundTripMs rather than pinning a 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, but it is a low-latency-link result.
What this cannot show. The proxy models latency, not bandwidth, so the 34% payload reduction jsonEncoding: 'positional' buys does not appear in these tables at all; it would only show on a bandwidth-constrained link. These tables predate that encoding becoming the PostgreSQL default, so the nested rows here were measured on the object encoding.
Caveats#
- All measurements are wall-clock from the client, including query execution and result serialization. Client and database share one host: no round-trip, no pooler hop.
- Prisma's nested scenarios run on its
joinstrategy, deliberately.benchmarks/prisma/schema.prismaenables therelationJoinspreview feature, which per Prisma's docs makesjointhe client-wide default, and the harness never overridesrelationLoadStrategyon a query. Without the flag Prisma would use itsquerystrategy and lose the nested scenarios by a wider margin. - Connection pool starvation, long transactions, and write-heavy workloads were not tested. Those are different benchmarks.
- The streaming scenario drains the full table, the shape that most flatters keyset pagination. A scenario that breaks out after the first match would instead show the cursor's cleanup advantages.
NODE_ENVis unset, which leaves Turbine's dev-mode cache cross-check guards armed. A control run withNODE_ENV=productionproduced Turbine numbers indistinguishable on every scenario.- The headline ten were measured against 0.71.0; the extended suite and the latency tables against 0.67.0. They are separate runs on the same machine and fixture, so compare within a section rather than across them. The nested-read rows in particular are not comparable across those dates, because the relation JSON encoding changed between them.
Reproduce#
Raw data, the full writeup and all three harnesses are committed to the repo:
benchmarks/RESULTS-0.71.0.mdthe headline-ten run in full, including the encoding control suite, the machine-state control, and the harness-disagreement table.benchmarks/RESULTS-EXTENDED.mdthe extended suite and the latency sweep in full, including the per-arm notes and the drift floor for each run.benchmarks/RESULTS-0.50.0.mdthe previous (0.50.0) run, including its per-run drift tables.benchmarks/RESULTS.mdthe historical runs.benchmarks/bench-interleaved.tsthe headline-ten harness.benchmarks/bench-extended.tsthe extended suite.benchmarks/bench-latency.tsthe cross-RTT sweep.
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.tsAny 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.