Skip to content

ORM Comparison

Hand-tinted plate of cartoon creatures, one per ORM, lined up on a shelf; the one on the right holds a rising chart
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;
}

In practice: Schema modeling is a trade-off. Prisma offers a clean DSL but adds a build step; Drizzle gives total SQL control via dialect-specific imports. MikroORM v7 recommends defineEntity (a programmatic, decorator-free style) and moved its decorators into a separate package that ships both flavors (@mikro-orm/decorators/legacy and @mikro-orm/decorators/es); TypeORM still requires the legacy experimentalDecorators plus emitDecoratorMetadata. UQL offers both styles and its decorators are the standard TC39 ones, so they need no compiler flags. These three keep the model in pure TS.

Where the model lives in decorated classes, the annotation and the property are two declarations that can disagree, and the legacy decorator spec had no way to compare them - @Column({ type: 'int' }) on a string compiles in TypeORM to this day. The standard spec hands a decorator the property’s type, 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 hold the type of the key it points at, or a generator that does not produce what its field declares. MikroORM’s standard-decorator entry point uses that context for relations - its @ManyToOne constrains the property to the target entity or its primary key - but its scalar @Property takes the field as unknown, so a type that contradicts the property still compiles. Drizzle and Prisma sidestep the question rather than answer it, by deriving the TypeScript types 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,
});

In practice: Drizzle ships native pgvector column types and helpers (cosineDistance, l2Distance) for PostgreSQL, and declares the HNSW/IVFFlat index in the schema so drizzle-kit emits it. MikroORM gets the same operators from pgvector’s own pgvector/mikro-orm adapter (VectorType plus the distance helpers). TypeORM has had a vector column type since 0.3.27 but no distance expression, so ordering by similarity is still a raw string; Prisma stays on raw SQL with database-specific operators (like <=>). All of these are Postgres-only. UQL handles vector search as a native, type-safe operator across 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,
});

In practice: Drizzle is for those who want to see the underlying SQL. Prisma, TypeORM, and UQL favor a declarative object style to reduce boilerplate. Notably, UQL and Prisma queries are plain JSON, making them easy to share across services or the network.


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 } },
},
});

In practice: Filtering a to-many relation while fetching it has converged: Prisma, MikroORM (populateFilter, or populateWhere when the condition should discard parents too), Drizzle’s relational query API, and UQL all express it inline. The shape and the reach still differ. Drizzle’s nested where is an operator callback rather than an object, and the type only offers it on to-many relations - object filters and a where on a to-one arrive with Relational Queries v2, which is still in the 1.0 beta line. Prisma has no where on a to-one relation either, while UQL’s $populate puts the condition on the join itself and $required: true promotes that join to an INNER JOIN. TypeORM’s plain find() options are the holdout: a nested relation condition there filters the parent rows as well, and is unreliable past the first level, so anything else 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,
});

In practice: Only Prisma and UQL express aggregation declaratively; Drizzle composes it with SQL function helpers, while MikroORM and TypeORM drop to the QueryBuilder. TypeORM’s rows come back untyped there (getRawMany(): any[]); MikroORM v7 does track the raw aliases in the type, so having({ avgAge: ... }) is checked against them, but their values arrive as unknown and still need narrowing. UQL’s aggregate is plain JSON - serializable and identical across every SQL engine and MongoDB - and typo-proof end to end: $group columns, the aggregated fields in $agg, the $having / $sort aliases, and the result rows are all typed. 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 accept an object filter for where (TypeORM’s QueryBuilder takes FindOperators like MoreThanOrEqual there too), while Drizzle needs a per-operator import (gte). But having/groupBy stay string-only on TypeORM’s QueryBuilder - there’s no object-literal equivalent, so the aggregation itself still isn’t fully declarative there. In UQL, $where in aggregate() runs through the very same filter engine as findMany, so soft-delete, default, and security filters apply to aggregates automatically - tenant scoping does not silently 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;
}

In practice: Manually mapping computed fields is brittle. MikroORM, TypeORM and UQL let you define the expression in the entity, where it behaves like a real column for sorting and filtering without touching the schema. Drizzle splits the two cases: an inline sql expression is restated per-query, or you declare a database generated column with generatedAlwaysAs, which is filterable and sortable but is a real column and so needs a migration (and, on PostgreSQL, has to be stored). Prisma is the only one with no database-side option: a $extends({ result: ... }) client extension centralizes the mapping and is type-safe, but it computes after the fetch, so it can neither filter nor 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);

In practice: Most single-row CRUD is similar across these tools. The main difference is the mental model: Drizzle and MikroORM require more focus on the database return or flush cycle, while Prisma, TypeORM, and UQL prioritize fire-and-forget methods.

Batch inserts are where these tools diverge most - and where UQL is as well the smartest ORM. Its 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

In practice: Hand-filtering deletedAt: null in every query is a bug magnet. TypeORM, MikroORM, and UQL handle the read side at the engine level, so deleted records stay hidden without manual discipline. The write side is where they part: TypeORM and UQL turn a delete into a timestamp for you, while MikroORM has no soft-delete concept in core - the filter hides the rows, but converting em.remove() into an update is your subscriber. UQL also ships first-class restoreOneById/restoreMany and a { hardDelete: true } escape hatch, and exposes soft-delete as one instance of its general query-filter primitive.


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'] },
},
});

In practice: Object-based filtering (Prisma, MikroORM, UQL) avoids per-operator imports and stays JSON-serializable, unlike function-based filtering (Drizzle, TypeORM). UQL types operators by field: $like only on strings, $gt on comparable types, $size on arrays, so { age: { $like: '3%' } } is a compile error. MikroORM’s operator map offers every operator on every field, so the same mistake compiles. Case-insensitive matching also diverges: MikroORM’s $ilike is PostgreSQL-only, while UQL’s $istartsWith/$iincludes compile to the right SQL on every supported 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 a unified 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 unified high-level JSON operator API.

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' } });

In practice: Every ORM in this comparison except UQL requires you to build your own API bridge per model. UQL’s HTTP transport core is framework-agnostic (the same handler mounts on Hono, Elysia, Next.js, Bun, Deno, Workers, or Express) and pairs with a typed browser client, including 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

In practice: UQL and MikroORM use an Entity-First approach where your code is the source of truth, diffing directly against the live database. This eliminates the “middleman” of proprietary DSLs (Prisma) or JSON snapshots (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);
}

In practice: Processing millions of rows requires native cursors to keep memory stable. MikroORM and UQL provide consistent AsyncIterable support across all their drivers (including MongoDB, where MikroORM streams root entities only and ignores populate), replacing event-emitters with clean for await loops. 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
Unified 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, shipped in both fast and small builds, CommonJS and ESM. Prisma 7 is “Rust-free” in the sense that no Rust binary runs at query time and the client runtime is TypeScript, but the compiler those WASM modules hold is still Rust. Shipping every engine is the same reason UQL ships every dialect, so the headline number on its own is not a fair thing to hold against it; the comparison worth making is the cost per dialect. Prisma’s PostgreSQL compiler alone is 4.9 MB in its fast build. UQL’s whole PostgreSQL entry point is 19.9 kB gzipped.

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


In our open benchmark of pure SQL-generation speed, UQL is the fastest entry in all 8 query categories, on average ~2.4x faster than the next fastest tool, including the standalone query builders Knex and Kysely. Every win ranges from 1.5x to 3.6x.