Skip to content

ORM Comparison

Every common operation in five TypeScript ORMs, side by side: Drizzle, MikroORM, Prisma, TypeORM, and UQL. Samples run alphabetically, so UQL is last in every group rather than first.

Hand-tinted plate of cartoon creatures, one per ORM, lined up on a shelf; the one on the right holds a rising chart

What we can offer is detail you can check: current majors as of August 2026 (Drizzle 0.45, MikroORM 7, Prisma 7, TypeORM 1.1, UQL 0.25), and code you can review and run rather than a verdict.

Drizzle
import { pgTable, serial, varchar, integer } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).unique(),
name: varchar('name', { length: 255 }),
companyId: integer('company_id').references(() => companies.id),
});
MikroORM
import { defineEntity, p } from '@mikro-orm/core';
const UserSchema = defineEntity({
name: 'User',
properties: {
id: p.integer().primary(),
email: p.string().unique(),
name: p.string(),
company: () => p.manyToOne(Company),
},
});
export class User extends UserSchema.class {}
UserSchema.setClass(User);
Prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
companyId Int
company Company @relation(fields: [companyId], references: [id])
}
TypeORM
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@Column({ unique: true }) email: string;
@Column() name: string;
@ManyToOne(() => Company) company: Company;
}
UQL
import { Entity, Id, Field, ManyToOne } from 'uql-orm';
@Entity()
export class User {
@Id({ type: Number }) id?: number;
@Field({ type: String, unique: true }) email?: string;
@Field({ type: String }) name?: string;
@Field({ type: Number, references: () => Company }) companyId?: number;
@ManyToOne({ entity: () => Company }) company?: Company;
}

Prisma’s DSL reads the shortest, but it is a separate language with a build step behind it. Everyone else keeps the model in TypeScript. Drizzle gives the most direct SQL control, through dialect-specific imports. MikroORM v7 prefers defineEntity and has moved its decorators into a separate package; TypeORM still needs experimentalDecorators and emitDecoratorMetadata in your tsconfig. UQL takes either style, and its decorators are the standard TC39 ones, so there are no compiler flags to turn on.

Decorated classes come with one catch worth knowing: the annotation and the property are two declarations that can disagree. The legacy decorator spec gave the decorator no way to compare them, which is why @Column({ type: 'int' }) on a string still compiles in TypeORM today. The standard spec passes the property’s type in, so UQL checks the two against each other: a type that contradicts the property, a relation whose entity is not what the property holds, a foreign key that does not match the key it points at. MikroORM does this for relations through its standard-decorator entry point, but its scalar @Property takes the field as unknown, so a contradicting type still compiles. Drizzle and Prisma avoid the question rather than answer it: the types come from the schema, so there is only ever one declaration.


Drizzle
import { cosineDistance } from 'drizzle-orm';
const results = await db.select()
.from(items)
.orderBy(cosineDistance(items.embedding, queryVector))
.limit(10);
MikroORM
import { cosineDistance } from 'pgvector/mikro-orm';
const results = await em.createQueryBuilder(Item)
.select('*')
.orderBy({ [cosineDistance('embedding', queryVector)]: 'ASC' })
.limit(10)
.getResult();
Prisma
const results = await prisma.$queryRaw`
SELECT * FROM "Item"
ORDER BY embedding <=> ${queryVector}::vector
LIMIT 10
`;
TypeORM
const results = await manager.createQueryBuilder(Item, 'item')
.orderBy('item.embedding <=> :vector')
.setParameter('vector', queryVector)
.limit(10)
.getMany();
UQL
const results = await pool.findMany(Item, {
$select: { id: true, name: true },
$sort: { embedding: { $vector: queryVector, $distance: 'cosine' } },
$limit: 10,
});

Drizzle has the most built in: pgvector column types, distance helpers like cosineDistance, and an HNSW/IVFFlat index declared in the schema for drizzle-kit to emit. MikroORM gets the same operators from pgvector’s own adapter. TypeORM has had a vector column since 0.3.27 but no distance expression, so ordering by similarity is a raw string, and Prisma stays on raw SQL with operators like <=>. All of those are PostgreSQL-only. UQL treats vector search as a typed operator on PostgreSQL, CockroachDB, MariaDB, SQLite, libSQL/Turso, and MongoDB Atlas.

Ability Drizzle MikroORM Prisma TypeORM UQL
Native Vector Operator ✅¹ 🔌² ❌³
Multi-Dialect Support 🔌⁴ 🔌⁴ 🔌⁴ 🔌⁴
Index Migration ✅¹ 🔌⁵ 🔌⁶
JSON Path Vectors

¹ PostgreSQL (pgvector) only. The index is declared in the schema - index().using('hnsw', table.embedding.op('vector_cosine_ops')) - and drizzle-kit generate emits the DDL. ² Through pgvector’s first-party pgvector/mikro-orm adapter, not @mikro-orm/core; PostgreSQL only. ³ TypeORM maps the column (@Column('vector', { length: 3 })) and diffs it, but has no distance operator: the ORDER BY is a hand-written string. ⁴ Every helper above is written for pgvector. On MariaDB’s VEC_DISTANCE_*, SQLite’s sqlite-vec, or MongoDB’s $vectorSearch you hand-write that dialect’s own SQL or pipeline. ⁵ Reachable through a raw index expression (@Index({ expression })), not a declared vector index. ⁶ Prisma’s schema DSL still emits a plain B-tree for @@index on a vector column; HNSW/IVFFlat need the Unsupported("vector(n)") escape hatch plus a hand-written SQL migration.


Drizzle
import { eq, desc } from 'drizzle-orm';
const results = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(eq(users.name, 'Jane'))
.orderBy(desc(users.createdAt))
.limit(10);
MikroORM
const results = await em.find(User, { name: 'Jane' }, {
fields: ['id', 'name', 'email'],
orderBy: { createdAt: 'DESC' },
limit: 10,
});
Prisma
const results = await prisma.user.findMany({
select: { id: true, name: true, email: true },
where: { name: 'Jane' },
orderBy: { createdAt: 'desc' },
take: 10,
});
TypeORM
const results = await manager.find(User, {
select: { id: true, name: true, email: true },
where: { name: 'Jane' },
order: { createdAt: 'DESC' },
take: 10,
});
UQL
const results = await pool.findMany(User, {
$select: { id: true, name: true, email: true },
$where: { name: 'Jane' },
$sort: { createdAt: 'desc' },
$limit: 10,
});

Drizzle is for people who want to see the SQL. Prisma, TypeORM, and UQL lean declarative to cut boilerplate. UQL and Prisma queries are plain JSON, so one can be handed across a service boundary as it is.


Drizzle
const results = await db.query.users.findMany({
columns: { id: true, name: true },
with: {
posts: {
columns: { title: true },
where: (post, { eq }) => eq(post.published, true),
},
},
});
MikroORM
const results = await em.find(User, {}, {
fields: ['id', 'name', 'posts.title'],
populate: ['posts'],
populateFilter: { posts: { published: true } },
});
Prisma
const results = await prisma.user.findMany({
select: {
id: true,
name: true,
posts: { select: { title: true }, where: { published: true } },
},
});
TypeORM
// find() options cannot filter a relation independently of its parent,
// so an independently filtered join needs the QueryBuilder:
const results = await manager
.createQueryBuilder(User, 'user')
.select(['user.id', 'user.name'])
.leftJoinAndSelect('user.posts', 'post', 'post.published = :published', { published: true })
.getMany();
UQL
const results = await pool.findMany(User, {
$select: { id: true, name: true },
$populate: {
posts: { $select: { title: true }, $where: { published: true } },
},
});

Filtering a to-many while you fetch it has largely converged: Prisma, MikroORM, Drizzle’s relational query API, and UQL all express it inline. The reach still differs. Drizzle’s nested where is an operator callback rather than an object, and only on to-many relations; object filters and a where on a to-one arrive with Relational Queries v2, still in the 1.0 beta line. Prisma has no where on a to-one either. UQL puts the condition on the join itself, and $required: true promotes it to an INNER JOIN. TypeORM’s plain find() is the holdout: a nested condition there filters the parents too, and gets unreliable past the first level, so anything real needs the QueryBuilder.


Drizzle
import { count, avg, gt, gte, desc } from 'drizzle-orm';
const results = await db
.select({ status: users.status, count: count(), avgAge: avg(users.age) })
.from(users)
.where(gte(users.createdAt, new Date('2025-01-01')))
.groupBy(users.status)
.having(({ avgAge }) => gt(avgAge, 30))
.orderBy(desc(count()))
.limit(10);
MikroORM
import { sql } from '@mikro-orm/core';
const results = await em
.createQueryBuilder(User, 'u')
.select(['u.status', sql`count(*)`.as('count'), sql`avg(u.age)`.as('avgAge')])
.where({ createdAt: { $gte: new Date('2025-01-01') } })
.groupBy('u.status')
.having({ avgAge: { $gt: 30 } })
.orderBy({ count: 'desc' })
.limit(10)
.execute('all');
Prisma
const results = await prisma.user.groupBy({
by: ['status'],
where: { createdAt: { gte: new Date('2025-01-01') } },
_count: { status: true },
_avg: { age: true },
having: { age: { _avg: { gt: 30 } } },
orderBy: { _count: { status: 'desc' } },
take: 10,
});
TypeORM
import { MoreThanOrEqual } from 'typeorm';
const results = await manager
.createQueryBuilder(User, 'user')
.select('user.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('AVG(user.age)', 'avgAge')
.where({ createdAt: MoreThanOrEqual(new Date('2025-01-01')) })
.groupBy('user.status')
.having('AVG(user.age) > :minAge', { minAge: 30 })
.orderBy('count', 'DESC')
.limit(10)
.getRawMany();
UQL
const results = await pool.aggregate(User, {
$where: { createdAt: { $gte: new Date('2025-01-01') } },
$group: { status: true },
$agg: { count: { $count: '*' }, avgAge: { $avg: 'age' } },
$having: { avgAge: { $gt: 30 } },
$sort: { count: -1 },
$limit: 10,
});

Only Prisma and UQL express aggregation declaratively. Drizzle composes it from SQL helpers, while MikroORM and TypeORM send you to the QueryBuilder, where TypeORM’s rows arrive untyped (getRawMany(): any[]). MikroORM v7 does track the raw aliases in the type, so having({ avgAge: ... }) is checked against them, but the values come back as unknown and still need narrowing. UQL’s aggregate is plain JSON, identical on every SQL engine and MongoDB, and typed end to end: $group columns, the $agg fields, the $having and $sort aliases, and the result rows. Prisma’s groupBy is type-safe too, but keyed by operator (_avg, _count) and SQL-only.

The pre-aggregation filter splits differently. MikroORM, Prisma, TypeORM, and UQL all take an object for where, while Drizzle wants a per-operator import (gte). But having and groupBy stay string-only on TypeORM’s QueryBuilder, so the aggregation there is never quite declarative. In UQL, $where in aggregate() runs through the same filter engine as findMany, so soft-delete, default, and security filters apply here too: tenant scoping does not quietly stop at GROUP BY.


Drizzle
import { sql, type SQL } from 'drizzle-orm';
// Per query, as a selected expression:
const results = await db.select({
id: users.id,
fullName: sql<string>`CONCAT(${users.firstName}, ' ', ${users.lastName})`
}).from(users);
// Or once in the schema, as a database generated column:
export const users = pgTable('users', {
firstName: varchar('first_name', { length: 255 }),
lastName: varchar('last_name', { length: 255 }),
fullName: varchar('full_name', { length: 511 }).generatedAlwaysAs(
(): SQL => sql`${users.firstName} || ' ' || ${users.lastName}`,
),
});
MikroORM
import { defineEntity, p } from '@mikro-orm/core';
const UserSchema = defineEntity({
name: 'User',
properties: {
firstName: p.string(),
lastName: p.string(),
// `cols.x` expands to the quoted, alias-qualified column
fullName: p.formula<string>(cols => `CONCAT(${cols.firstName}, ' ', ${cols.lastName})`),
},
});
export class User extends UserSchema.class {}
UserSchema.setClass(User);
Prisma
// Prisma doesn't support computed fields in the schema,
// so you usually map the results manually in your code:
const users = await prisma.user.findMany();
const results = users.map(u => ({
...u,
fullName: `${u.firstName} ${u.lastName}`
}));
TypeORM
import { Entity, Column, VirtualColumn, AfterLoad } from 'typeorm';
@Entity()
class User {
@Column() firstName: string;
@Column() lastName: string;
// SQL-based virtual field (Added in v0.3.x)
@VirtualColumn({
query: (alias) => `SELECT CONCAT(${alias}.firstName, ' ', ${alias}.lastName)`
})
fullName: string;
}
UQL
import { raw } from 'uql-orm';
@Entity()
class User {
@Field({ type: String }) firstName: string;
@Field({ type: String }) lastName: string;
@Field({
type: String,
virtual: raw(({ escapedPrefix }) =>
`CONCAT(${escapedPrefix}.firstName, ' ', ${escapedPrefix}.lastName)`)
})
fullName?: string;
}

Mapping computed fields by hand is brittle. MikroORM, TypeORM, and UQL let you put the expression in the entity, where it behaves like a real column for sorting and filtering with no schema change. Drizzle splits the two cases: an inline sql expression you restate per query, or a database generated column via generatedAlwaysAs, which is filterable and sortable but is a real column, so it needs a migration (and stored on PostgreSQL). Prisma is the only one with no database-side option. A $extends({ result: ... }) extension centralizes the mapping and is type-safe, but it computes after the fetch, so you cannot filter or sort by the value.


Drizzle
import { eq } from 'drizzle-orm';
const [user] = await db.insert(users).values({ name: 'Jane' }).returning();
await db.update(users).set({ name: 'Jane D.' }).where(eq(users.id, user.id));
await db.update(users).set({ status: 'archived' }).where(eq(users.status, 'inactive'));
await db.delete(users).where(eq(users.id, user.id));
MikroORM
const user = em.create(User, { name: 'Jane' });
await em.flush();
await em.nativeUpdate(User, { id: user.id }, { name: 'Jane D.' });
await em.nativeUpdate(User, { status: 'inactive' }, { status: 'archived' });
await em.nativeDelete(User, { id: user.id });
Prisma
const user = await prisma.user.create({ data: { name: 'Jane' } });
await prisma.user.update({ where: { id: user.id }, data: { name: 'Jane D.' } });
await prisma.user.updateMany({ where: { status: 'inactive' }, data: { status: 'archived' } });
await prisma.user.delete({ where: { id: user.id } });
TypeORM
const user = manager.create(User, { name: 'Jane' });
await manager.save(user);
await manager.update(User, user.id, { name: 'Jane D.' });
await manager.update(User, { status: 'inactive' }, { status: 'archived' });
await manager.delete(User, user.id);
UQL
const id = await pool.insertOne(User, { name: 'Jane' });
await pool.updateOneById(User, id, { name: 'Jane D.' });
await pool.updateMany(User, { $where: { status: 'inactive' } }, { status: 'archived' });
await pool.deleteOneById(User, id);

Single-row CRUD looks much the same everywhere. What differs is the mental model: Drizzle and MikroORM ask you to think about the database return or the flush cycle, while Prisma, TypeORM, and UQL are fire-and-forget.

Batch inserts are where they diverge most. UQL’s insertMany:

  • returns an id per row on every database;
  • accepts records with different column sets in one statement - a missing cell takes the column’s default;
  • automatically chunks a batch that would exceed the driver’s bind-parameter limit.

Those ids are exact where the database reports them per row: PostgreSQL, CockroachDB, MariaDB, and SQLite (including LibSQL/Turso, Cloudflare D1, and Bun SQL) via RETURNING, and MongoDB via insertedIds. Only MySQL has no RETURNING, so its ids are inferred - and only when it is safe to do so, otherwise the entry is undefined instead of a wrong value.

By comparison, Prisma’s createMany returns only a row count (createManyAndReturn returns ids, but not on MySQL). Drizzle, TypeORM, and MikroORM all use RETURNING where the database has it; on MySQL, which does not, they derive the batch’s ids by counting up from the single reported insertId - so a batch mixing explicit and auto-generated ids comes back misnumbered there.


Drizzle
import { eq, isNull } from 'drizzle-orm';
// No built-in soft delete. Requires manual filter management:
await db.update(users).set({ deletedAt: new Date() }).where(eq(users.id, 1));
const results = await db.select().from(users).where(isNull(users.deletedAt));
MikroORM
import { defineEntity, p } from '@mikro-orm/core';
const UserSchema = defineEntity({
name: 'User',
properties: {
id: p.integer().primary(),
deletedAt: p.datetime().nullable(),
},
filters: {
softDelete: { name: 'softDelete', cond: { deletedAt: null }, default: true },
},
});
export class User extends UserSchema.class {}
UserSchema.setClass(User);
// Queries auto-filter soft-deleted rows; use an onFlush subscriber
// to convert em.remove() calls into deletedAt updates.
Prisma
// No built-in soft delete. Requires manual field management:
await prisma.user.update({ where: { id: 1 }, data: { deletedAt: new Date() } });
const results = await prisma.user.findMany({ where: { deletedAt: null } });
TypeORM
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@DeleteDateColumn() deletedAt: Date;
}
// Native support for soft-deletion and automatic filtering
await manager.softDelete(User, id);
UQL
@Entity()
export class User {
@Id({ type: Number }) id: number;
@Field({ type: Date, softDelete: true }) deletedAt: Date;
}
// Marking the field enables global soft-deletion behavior
await pool.deleteOneById(User, id); // soft delete
await pool.restoreOneById(User, id); // bring it back
await pool.deleteOneById(User, id, { hardDelete: true }); // remove for good

Filtering deletedAt: null by hand in every query is a bug waiting to happen. TypeORM, MikroORM, and UQL all handle the read side in the engine, so deleted rows stay hidden without any discipline on your part. The write side is where they part: TypeORM and UQL turn a delete into a timestamp for you, while MikroORM has no soft-delete in core, so converting em.remove() into an update is a subscriber you write. UQL also ships restoreOneById/restoreMany and a { hardDelete: true } escape hatch, and treats soft-delete as one case of its general query filters.


Drizzle
import { gte, lte, ilike, notInArray, and } from 'drizzle-orm';
const results = await db.select().from(users).where(
and(
gte(users.age, 18),
lte(users.age, 65),
ilike(users.name, 'A%'),
notInArray(users.status, ['banned', 'inactive']),
),
);
MikroORM
const results = await em.find(User, {
age: { $gte: 18, $lte: 65 },
name: { $ilike: 'A%' },
status: { $nin: ['banned', 'inactive'] },
});
Prisma
const results = await prisma.user.findMany({
where: {
age: { gte: 18, lte: 65 },
name: { startsWith: 'A', mode: 'insensitive' },
status: { notIn: ['banned', 'inactive'] },
},
});
TypeORM
import { Between, ILike, Not, In } from 'typeorm';
const results = await manager.findBy(User, {
age: Between(18, 65),
name: ILike('A%'),
status: Not(In(['banned', 'inactive'])),
});
UQL
const results = await pool.findMany(User, {
$where: {
age: { $gte: 18, $lte: 65 },
name: { $istartsWith: 'A' },
status: { $nin: ['banned', 'inactive'] },
},
});

Object filters (Prisma, MikroORM, UQL) need no per-operator imports and stay JSON-serializable; function filters (Drizzle, TypeORM) give up both. UQL types its operators by field, so $like is offered on strings, $gt on comparable types, $size on arrays, and { age: { $like: '3%' } } is a compile error. MikroORM offers every operator on every field, so the same mistake compiles. Case-insensitive matching diverges too: MikroORM’s $ilike is PostgreSQL-only, while UQL’s $istartsWith/$iincludes compile to the right SQL on every dialect.


JSON / JSONB Operators (Practical Coverage)

Section titled “JSON / JSONB Operators (Practical Coverage)”
JSON capability Drizzle MikroORM Prisma TypeORM UQL
Nested / Dot-notation JSON filtering ❌¹ ✅² ✅³ 🔌⁴
Atomic JSON key merge/update ❌¹ ❌² 🔌⁴ 🔌⁴
Atomic JSON key removal (unset) ❌¹ ❌²
Atomic JSON array append (push) ❌¹ ❌²
JSON array query operators (size, all, elemMatch) ❌¹ ✅² 🔌⁴ 🔌⁴
Same JSON API across 4 SQL dialects ✅²

¹ Requires raw SQL. ² MikroORM provides one interface for querying JSON properties (via nested objects and $elemMatch) natively across SQL dialects, but relies on full object substitution rather than atomic diffing operators for JSON mutations. ³ Prisma advanced JSON filtering is available on selected connectors and has connector-specific limitations. ⁴ Achievable with dialect-specific SQL expressions or query-builder escape hatches, not a high-level JSON operator API that works the same on every dialect.

See JSON / JSONB for generated SQL examples and practical baseline dialect versions.


Drizzle
import { eq } from 'drizzle-orm';
app.get('/api/users', async (req, res) => {
const results = await db.select().from(users).where(eq(users.id, req.query.id));
res.json(results);
});
MikroORM
const query = { status: 'active' };
const results = await em.find(User, query);
Prisma
const results = await prisma.user.findMany({ where: { status: 'active' } });
TypeORM
app.get('/api/users', async (req, res) => {
const results = await manager.find(User, { where: { id: req.query.id } });
res.json(results);
});
UQL
// Backend: auto-generated REST API for your entities
import { createFetchHandler } from 'uql-orm/http';
const handler = createFetchHandler({ include: [User] });
// Frontend (Client-side)
import { HttpQuerier } from 'uql-orm/browser';
const http = new HttpQuerier('/api');
const { data: results } = await http.findMany(User, { $where: { status: 'active' } });

Every ORM here except UQL leaves the API layer to you, one bridge per model. UQL’s HTTP transport is framework-agnostic, so the same handler mounts on Hono, Elysia, Next.js, Bun, Deno, Workers, or Express, and it pairs with a typed browser client that carries transactions and authorization hooks.


Drizzle
// 1. You edit your TS schema
// 2. You run a CLI command to generate a JSON "snapshot"
// 3. You run another command to generate a SQL migration from that snapshot
// 4. Finally, you apply the SQL to your database
npx drizzle-kit generate // dialect comes from drizzle.config.ts
npx drizzle-kit push
MikroORM
// 1. You edit your entities
// 2. MikroORM diffs your metadata against the live DB (or a schema dump)
// 3. It generates a TS/JS migration file
await orm.getMigrator().createMigration();
await orm.getMigrator().up();
Prisma
// 1. You edit the proprietary .prisma file
// 2. You run a 'dev' command which requires a "Shadow Database" to diff
// 3. Prisma generates a SQL file and applies it
npx prisma migrate dev --name add_nickname
TypeORM
// 1. You edit your entities
// 2. TypeORM can auto-sync in dev (dangerous for production)
// 3. Or you manually generate a migration by diffing against a live DB
npx typeorm migration:generate -d ./data-source.ts ./migrations/AddNickname
UQL
// 1. You edit your entity class
// 2. UQL diffs YOUR CODE directly against the live database
// 3. It auto-generates a clean, timestamped DDL migration
npx uql-migrate generate:entities add_nickname
npx uql-migrate up

UQL and MikroORM are entity-first: your code is the source of truth, and the diff runs against the live database. Nothing sits in between, neither a DSL of its own (Prisma) nor a JSON snapshot to keep in sync (Drizzle).


Drizzle
// `.iterator()` exists on the MySQL-family sessions only (mysql2, PlanetScale,
// TiDB, SingleStore); the PostgreSQL and SQLite drivers have no equivalent.
const stream = await db.select().from(users).iterator();
for await (const user of stream) {
await writeToCsv(user);
}
MikroORM
const stream = await em.stream(User, { status: 'active' });
for await (const user of stream) {
await writeToCsv(user);
}
Prisma
// Natively, this requires manual cursor pagination
let cursor: number | undefined;
while (true) {
const batch = await prisma.user.findMany({
take: 100, skip: cursor ? 1 : 0, cursor: cursor ? { id: cursor } : undefined
});
if (batch.length === 0) break;
for (const user of batch) await writeToCsv(user);
cursor = batch[batch.length - 1].id;
}
TypeORM
// Node stream of raw, un-hydrated rows; on PostgreSQL it also
// requires the extra `pg-query-stream` package.
const results = await manager.createQueryBuilder(User, 'user').stream();
results.on('data', (row) => writeToCsv(row));
UQL
const results = await pool.findManyStream(User, { $where: { status: 'active' } });
for await (const user of results) {
await writeToCsv(user);
}

Millions of rows need a real cursor to keep memory flat. MikroORM and UQL give you AsyncIterable on every driver, so it is a plain for await loop rather than event handlers (on MongoDB, MikroORM streams root entities only and ignores populate). Drizzle’s iterator covers only its MySQL-family drivers, and TypeORM’s stream hands back raw rows.


Features marked as: ✅ native, 🔌 via extension/plugin, ❌ not available.

Capability Drizzle MikroORM Prisma TypeORM UQL
Native semantic search ✅¹ 🔌⁶ 🔌⁶ 🔌⁶
Virtual fields (computed) ✅⁷ 🔌⁸
No custom DSL needed
No codegen needed
Serializable queries (JSON)
Deep relation operators
Cursor streaming (AsyncIterable) ✅⁹ 🔌¹⁰
Soft delete (built-in) + restore 🔌 🔌 ✅⁴
Global query filters (scopes) 🔌
Multi-tenancy / RLS ✅⁵ 🔌 ✅⁵
Lifecycle hooks 🔌
Works without an active ORM context (no Unit of Work / flush cycle)
Auto REST API
Browser querier
MongoDB support
One query mental model across SQL + MongoDB 🔌² ❌³

¹ PostgreSQL (pgvector) only. ² Prisma Client is broadly consistent across SQL/Mongo, but connector-specific capabilities and raw-query APIs diverge. ³ TypeORM’s Mongo path diverges from SQL behavior (for example, QueryBuilder support differs). ⁴ TypeORM soft-deletes and restores natively (@DeleteDateColumn + restore()); MikroORM’s filters hide the rows but the delete-to-timestamp conversion is yours to write, and Prisma needs a client extension. ⁵ This row is about ORM-level tenant scoping. UQL’s security filters are non-bypassable and fail closed when the request context is missing; MikroORM has native tenant filters, but any query can disable them (filters: false); Prisma relies on client extensions (bypassable). Note this is app-layer enforcement in all three - raw SQL escapes it. Database-native row-level security is a stronger, complementary boundary (the DB enforces it regardless of app code): Drizzle can define Postgres RLS policies in the schema (pgPolicy / crudPolicy), and Prisma documents driving Postgres RLS via an extension. For the strongest isolation, combine an app-level filter with DB-native RLS. ⁶ See the Semantic Search table for what each 🔌 is: MikroORM has pgvector’s own adapter, TypeORM a vector column type with hand-written distance SQL, Prisma raw SQL or a community client extension. All three are PostgreSQL-only. ⁷ Either restated per query as a sql expression, or declared once as a database generated column (generatedAlwaysAs), which is a real column and so needs a migration. ⁸ A $extends({ result: ... }) client extension centralizes the mapping, but computes it after the fetch, so it can’t be filtered or sorted in the database. ⁹ MySQL-family drivers only (mysql2, PlanetScale, TiDB, SingleStore); .iterator() does not exist on the PostgreSQL or SQLite sessions. ¹⁰ stream() returns a Node stream of raw, un-hydrated rows, and needs the extra pg-query-stream package on PostgreSQL.


Database Drizzle MikroORM Prisma TypeORM UQL
Cloudflare D1 🔌¹
CockroachDB
LibSQL / Turso
MariaDB
MongoDB
MSSQL ✅²
MySQL
Neon Serverless 🔌³ 🔌³
Oracle
PostgreSQL
SQLite

¹ MikroORM Cloudflare D1 support is currently documented as an experimental path through its SQL/Kysely integration. ² Drizzle MSSQL support shipped in the v1.0 beta line (not yet in a stable release). ³ No dedicated adapter: you hand the pg-compatible @neondatabase/serverless pool to the PostgreSQL driver yourself (TypeORM’s driver option, MikroORM’s driverOptions). Drizzle, Prisma and UQL each ship a Neon entry point.


What each one puts on disk before you add a driver. Measured on a fresh install of the package alone, August 2026.

Drizzle MikroORM Prisma TypeORM UQL
Installed 9.9 MB 4.7 MB 75.0 MB 22.5 MB 1.0 MB
Files 2,667 1,153 94 3,663 384

Packages measured: drizzle-orm 0.45.2, @mikro-orm/postgresql 7.1.10, @prisma/client 7.9.1, typeorm 1.1.0, uql-orm 0.24.5. Every row is the ORM on its own: add pg to any of them and they all grow by the same amount.

93% of @prisma/client is one thing: query compilers cross-compiled to WebAssembly, one per engine it supports, in both fast and small builds, CommonJS and ESM. Prisma 7 is “Rust-free” in that no Rust binary runs at query time and the client runtime is TypeScript, but the compiler inside those WASM modules is still Rust. Shipping every engine is the same call UQL makes with dialects, so the headline number alone is not fair to hold against it. Per dialect is the comparison that holds: Prisma’s PostgreSQL compiler is 4.9 MB in the fast build, against 19.9 kB gzipped for UQL’s entire PostgreSQL entry point.

UQL installs one package, no runtime dependencies, 305 kB on the wire, every dialect included. The pure fetch drivers (Turso Cloud, Neon, Cloudflare D1) stay that way end to end, so an edge bundle pulls in no native binaries. The costs are real: it is ESM-only, so there is no require() path, and it needs Node 24 or newer. CI budgets the per-entry sizes and fails the build when they regress.


In our open benchmark, which times a full PostgreSQL lifecycle per entry, UQL adds the least over hand-written driver code of any ORM: 278µs, against 621µs for the next closest and 1,889µs for the slowest.