ORM Benchmark
The open-source benchmark runs each ORM through the same PostgreSQL lifecycle: insert, read, update, read, nested read, delete, read. These are the numbers from its latest run.
PostgreSQL 18.6 (Homebrew), Bun 1.4.2, Apple M4 Max, October 2026. Median µs per operation over 250 rounds, after 125 warmup rounds, interleaved and rotated. Every median is ±3.7% or tighter at 95% confidence.
Versions: Drizzle 0.45.4 · MikroORM 7.2.5 · Prisma 7.10.0 · Sequelize 6.37.8 · TypeORM 1.1.2 · UQL 0.100.3.
Results
Section titled “Results”Adds is what the ORM itself costs on top of the driver. The two ref rows are the floors: hand-written SQL with the rows mapped by hand. Bun SQL entries are measured against bun sql, the rest against raw pg, so a fast driver never counts as the ORM’s win.
| # | Entry | Adds µs | Total µs |
|---|---|---|---|
| ref | bun sql | floor | 1095 |
| ref | raw pg | floor | 1114 |
| 1 | UQL (bunSql) | +261 | 1356 |
| 1 | UQL | +308 | 1422 |
| 1 | Drizzle (bunSql) | +352 | 1447 |
| 2 | Drizzle | +384 | 1498 |
| 4 | TypeORM | +492 | 1606 |
| 6 | Sequelize | +825 | 1939 |
| 7 | Prisma | +957 | 2071 |
| 8 | MikroORM | +1503 | 2617 |
Entries share a place when their confidence intervals overlap, meaning this run cannot tell them apart.
Totals only span 1.9x, because every entry pays the same database cost. What the ORM itself adds spans 6x: 261µs for UQL (bunSql), 1503µ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 66µs, but only 47µs of that is UQL: the other 19µs is the gap between the two floors, free to anything on that driver.
UQL keeps its share small by working out schema metadata once at startup, so nothing is looked up while a query runs, and by writing SQL straight into a string buffer, with no intermediate builder objects.
Per step
Section titled “Per step”The three steps that move the most data. The interactive charts show all seven.
| Entry (µs) | insert | read | nested | Total, 7 steps |
|---|---|---|---|---|
| bun sql | 333 | 172 | 190 | 1095 |
| raw pg | 316 | 195 | 205 | 1114 |
| UQL (bunSql) | 354 | 214 | 308 | 1356 |
| UQL | 369 | 247 | 330 | 1422 |
| Drizzle (bunSql) | 396 | 233 | 343 | 1447 |
| Drizzle | 402 | 247 | 375 | 1498 |
| TypeORM | 442 | 308 | 319 | 1606 |
| Sequelize | 445 | 367 | 494 | 1939 |
| Prisma | 865 | 270 | 386 | 2071 |
| MikroORM | 421 | 697 | 867 | 2617 |
Columns: insert is INSERT 10 rows, returning ids; read is SELECT with WHERE, SORT, LIMIT 200; nested is SELECT 50 parents with their children. The biggest gap is MikroORM’s nested: 867µs against 308-494µ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 474-633µs of each total and separating the field by at most 95µs.
Two runtimes
Section titled “Two runtimes”The same lifecycle on Bun and Node, from one Bun-built bundle, so the runtime is the only variable. Bun SQL entries are left out because that client exists only on Bun.
Bun 1.4.2, Node 24.20.0, all running the same bundled JavaScript, one at a time against the same database. PostgreSQL 18.6 (Homebrew), Apple M4 Max, October 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 |
|---|---|---|---|---|
| raw pg | 1131 | 3580 | 1225 | 4120 |
| UQL | 1435 | 4624 | 1584 | 5901 |
| Drizzle | 1527 | 5017 | 1786 | 6436 |
| TypeORM | 1576 | 5695 | 1837 | 7508 |
| Sequelize | 1871 | 6795 | 2202 | 8640 |
| Prisma | 1964 | 6610 | 2356 | 9084 |
| MikroORM | 2635 | 10324 | 3834 | 14603 |
On raw pg, where the code is identical, the runtimes are 94µs apart at p50 but 540µs apart at p99: Bun leads both, and each p99 is 217% on Bun, 236% on Node above its own p50. Switching runtime moves any single entry by at most 1199µs at p50 (MikroORM), where switching ORM on one runtime moves it 1149-2216µs. Both are differences of measured medians, known to ±45µs and ±39µs, so read them as ranges rather than as a ranking. Ranked by what each ORM adds, every runtime agrees: UQL < Drizzle < TypeORM < Sequelize < Prisma < MikroORM.
Memory
Section titled “Memory”What the same lifecycle allocates on Node, one process per entry. Adds KB is the heap allocated above the hand-written floor, per request.
PostgreSQL 18.6 (Homebrew), Node 24.20.0, Apple M4 Max, October 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 1% of its own (MikroORM).
| Entry | insert | read | nested | Total KB | Adds KB |
|---|---|---|---|---|---|
| raw pg | 14 | 87 | 105 | 240 | floor |
| UQL | 52 | 110 | 87 | 355 | +115 |
| Drizzle | 141 | 251 | 243 | 763 | +523 |
| TypeORM | 126 | 295 | 503 | 1066 | +826 |
| Prisma | 274 | 221 | 374 | 1076 | +836 |
| Sequelize | 100 | 425 | 585 | 1306 | +1066 |
| MikroORM | 83 | 1477 | 2089 | 3917 | +3677 |
Above the floor the field spans 32.0x: 115KB for UQL, 3677KB for MikroORM, and nested opens it widest: MikroORM’s 2089KB against UQL’s 87KB.
Almost none of it survives: another 60 lifecycles, collected either side, leave every heap smaller than it started, identity maps included. What the table prices is collector pressure, not a resident set that grows.
Type safety
Section titled “Type safety”The benchmark also writes ordinary mistakes in each ORM’s own API and counts which ones the compiler rejects. A ✅ means the mistake failed to compile and its corrected version compiled clean.
Checked with TypeScript 7.0.2, 12 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 | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| Sum over a text column | ❌ | ❌ | ✅ | ❌ | ✅ | ✅ |
| Misspelled column inside a loaded relation | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ |
| Misspelled column in inserted data | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Required column left out of 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 12 | 10 | 10 | 11 | 6 | 10 | 12 |
UQL catches 12 of the 12, Sequelize 6. 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.
Columns are alphabetical, not ranked. The queries behind each mark are in the benchmark, and Type Safety lets you try them in an editor.
Method
Section titled “Method”Every entry defines the same Company and User, runs the same seven steps through its own idiomatic API, and gets one connection with no pool. Entries are interleaved and rotated, and every step asserts on the rows it returns, so a step that silently does nothing fails. Numbers are medians, so one GC pause cannot dominate. The type-safety probes compile with TypeScript 7.0.2 at strict, against the same entities the timed lifecycle uses.
To reproduce every table against a local PostgreSQL:
git clone https://github.com/rogerpadilla/ts-orm-benchmark.gitcd ts-orm-benchmarkbun install
DATABASE_URL=postgres:///postgres bun run benchDATABASE_URL=postgres:///postgres bun run bench.runtimesDATABASE_URL=postgres:///postgres bun run bench.memorybun run bench.typesFor how the APIs and features line up, see the comparison.
Try it
npm install uql-ormEntities are plain classes and queries are plain JSON: nothing to generate, and no schema file to keep in sync.