Skip to content
NewComposite primary keys5 min read

ORM Benchmark

Our open-source benchmark puts each ORM through a full PostgreSQL lifecycle: insert, read, update, read, nested read, delete, read. Here is the latest run.

Close-up of four sprinters' legs and spiked running shoes mid-stride on a red athletics track

PostgreSQL 18.6 (Homebrew), Bun 1.3.14, Apple M4 Max, September 2026. Median µs per operation over 250 rounds, after 125 warmup rounds, interleaved and rotated. Every median is ±1.7% or tighter at 95% confidence (widest: Sequelize).

Versions: Drizzle 0.45.2 · MikroORM 7.1.15 · Prisma 7.10.0 · Sequelize 6.37.8 · TypeORM 1.1.1 · UQL 0.39.0.

Adds is what the ORM itself costs, everything on top of what the driver would have spent anyway. The two ref rows are the floors: hand-written SQL with the rows mapped by hand, which is what an ORM has to earn its keep against. Bun SQL entries are measured against bun sql and the rest against raw pg, so a fast driver is never counted as the ORM’s doing.

# Entry Adds µs Total µs
ref bun sql floor 1069
ref raw pg floor 1162
1 UQL (bunSql) +181 1250
1 UQL +208 1370
3 TypeORM +571 1733
3 Drizzle (bunSql) +592 1661
3 Drizzle +614 1776
6 Sequelize +914 2076
6 Prisma +953 2115
8 MikroORM +1729 2891

Entries share a place when their confidence intervals overlap: an equal number means a difference this run cannot resolve, not a tie broken in someone’s favour.

Totals only span 2.3x, because every entry pays the same database cost. What the ORM itself adds spans 10x: 181µs for UQL (bunSql), 1729µs for MikroORM.

Each entry is measured against its own driver’s floor, so a faster driver is never counted as the ORM’s win. Running the same UQL code on Bun SQL instead of pg saves 120µs, but only 27µs of that is UQL: the other 93µs is the gap between the two floors, free to anything on that driver.

The three steps where how much data is bound and hydrated decides the number. The interactive charts break down every step.

Operation (µs) bun sql raw pg UQL (bunSql) UQL TypeORM Drizzle (bunSql) Drizzle Sequelize Prisma MikroORM
INSERT 10 rows, returning ids 340 330 348 359 469 500 514 465 910 443
SELECT with WHERE, SORT, LIMIT 200 165 214 202 263 355 247 286 411 291 817
SELECT 50 parents with their children 184 228 266 313 379 420 471 584 393 952
Total, all 7 steps 1069 1162 1250 1370 1733 1661 1776 2076 2115 2891

The biggest gap is MikroORM’s nested: 952µs against 266-584µs for everyone else. The other 4 steps are asserted every round but not published: they are round trips with almost nothing in them, worth 434-679µs of each total and separating the field by at most 121µs.

The same lifecycle runs on Bun, Node and Deno, from one bundle built by Bun so the runtime is the only variable. The Bun SQL rows sit out here, since that client is a Bun API.

Bun 1.3.14, Node 24.20.0, Deno 2.9.6, all running the same bundled JavaScript, one at a time against the same database. PostgreSQL 18.6 (Homebrew), Apple M4 Max, September 2026. µs for a whole lifecycle, nearest-rank percentiles over 2000 rounds after 250 warmup, so a p99 is drawn from the 21 slowest rounds.

Entry (µs) Bun p50 Bun p99 Node p50 Node p99 Deno p50 Deno p99
raw pg 1347 7382 1282 7061 1234 3924
UQL 1629 8507 1563 8592 1429 4909
TypeORM 2045 12055 1948 10431 1654 6345
Drizzle 2127 10292 1831 9904 1716 5720
Prisma 2417 11785 2395 12641 2373 8130
Sequelize 2469 13358 2397 12239 2073 7344
MikroORM 3403 16799 4343 19394 3768 12751

On raw pg, the same code on all of them, the runtimes are 113µs apart at p50 but 3458µs apart at p99: Deno leads both, and each p99 is 448% on Bun, 451% on Node, 218% on Deno above its own p50. Switching runtime moves any single entry by at most 940µs at p50 (MikroORM), where switching ORM on one runtime moves it 1645-2718µs, so the ORM is the bigger decision here. 2 pairs change places between runtimes, the closest Prisma and Sequelize at 26µs.

The same lifecycle weighed for what it allocates, on Node, one process per entry. Adds KB is the heap above the hand-written floor, per request.

PostgreSQL 18.6 (Homebrew), Node 24.20.0, Apple M4 Max, September 2026. Median KB allocated per step over 60 rounds after 60 warmup of a 7-step lifecycle. Rounds a garbage collection ran in are discarded, never corrected, and no entry lost more than 2% of its own (MikroORM).

Entry insert read nested Total KB Adds KB
raw pg 14 87 106 245 floor
UQL 42 132 184 446 +201
Drizzle 140 251 242 769 +524
Prisma 273 220 373 1053 +808
TypeORM 126 294 503 1065 +820
Sequelize 100 426 592 1296 +1051
MikroORM 77 1470 2060 3864 +3619

Above the floor the field spans 18.0x: 201KB for UQL, 3619KB for MikroORM, and nested opens it widest: MikroORM’s 2060KB against UQL’s 184KB.

Almost none of it survives: another 60 lifecycles, collected either side, leave at most 42KB behind (Sequelize), identity maps included. What the table prices is collector pressure, not a resident set that grows.

The same benchmark writes ten ordinary mistakes - a misspelled column in a projection, a filter on a column that is not there, a text operator on a number, a read of a column the projection left out - in each ORM’s own API, and reports which ones the compiler refuses. Each file is compiled twice, once as written and once with every mistake corrected, so a green mark means the mistake errored and the correction was clean.

Checked with TypeScript 7.0.2, 10 probes per entry.

Mistake Drizzle MikroORM Prisma Sequelize TypeORM UQL
Misspelled column in the projection
Misspelled column in the filter
String value against a numeric column
Text operator against a numeric column
Misspelled column in the sort
Misspelled column inside a loaded relation
Misspelled column in inserted data
Number written into a text column
Reading a column the projection left out
Reading a misspelled column off a loaded relation
Caught, of 10 9 9 9 5 9 10

UQL catches 10 of the 10, Sequelize 5. Every mistake here is caught by at least one entry. The corrected copy of every file compiles clean, which is what makes a red mark a missing check rather than a broken query.

The rows are alphabetical, not ranked: ten probes cannot separate six ORMs the way a microsecond can, and four of them tie. The queries behind each mark are in the benchmark.

  • Schema metadata is worked out once at startup, so nothing is looked up while a query runs.
  • SQL is written straight into a string buffer, with no intermediate builder objects.
  • $populate loads a to-many relation with one batched query covering every parent, so the statement count follows the query’s shape rather than the result’s size.
  • Everyone defines the same Company and User, runs the same seven steps through its own idiomatic API, and gets one connection with no pooling. Entries are interleaved and rotated, so none of them keeps a favourable position.
  • Every step asserts on the rows it returns, so a step that silently does nothing fails instead of scoring well.
  • Medians, never means, so one GC pause cannot dominate a number, and each carries the 95% confidence interval the caption above states. Places are assigned over those intervals, so entries the run cannot separate share one instead of being ordered by noise.
  • The runtime figures come from one Bun-built bundle run on each runtime in turn, so no runtime is charged for its own TypeScript loader. The memory figures come from a process per entry, since a shared heap cannot be attributed.
  • The type-safety probes are compiled with TypeScript 7.0.2 at strict, against the same entity definitions the timed lifecycle queries: one definition per ORM, scored and measured, so neither half is judged on a model the other never used.
Terminal window
git clone https://github.com/rogerpadilla/ts-orm-benchmark.git
cd ts-orm-benchmark
bun install
DATABASE_URL=postgres:///postgres bun run bench
DATABASE_URL=postgres:///postgres bun run bench.runtimes
DATABASE_URL=postgres:///postgres bun run bench.memory
bun run bench.types

Speed is only one axis. For how the APIs and features line up, see the comparison.