ORM Comparison
Schema Definition
Section titled “Schema Definition”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),});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);model User { id Int @id @default(autoincrement()) email String @unique name String? companyId Int company Company @relation(fields: [companyId], references: [id])}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;}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.
Semantic Search
Section titled “Semantic Search”import { cosineDistance } from 'drizzle-orm';
const results = await db.select() .from(items) .orderBy(cosineDistance(items.embedding, queryVector)) .limit(10);import { cosineDistance } from 'pgvector/mikro-orm';
const results = await em.createQueryBuilder(Item) .select('*') .orderBy({ [cosineDistance('embedding', queryVector)]: 'ASC' }) .limit(10) .getResult();const results = await prisma.$queryRaw` SELECT * FROM "Item" ORDER BY embedding <=> ${queryVector}::vector LIMIT 10`;const results = await manager.createQueryBuilder(Item, 'item') .orderBy('item.embedding <=> :vector') .setParameter('vector', queryVector) .limit(10) .getMany();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.
Find: Selection & Filtering
Section titled “Find: Selection & Filtering”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);const results = await em.find(User, { name: 'Jane' }, { fields: ['id', 'name', 'email'], orderBy: { createdAt: 'DESC' }, limit: 10,});const results = await prisma.user.findMany({ select: { id: true, name: true, email: true }, where: { name: 'Jane' }, orderBy: { createdAt: 'desc' }, take: 10,});const results = await manager.find(User, { select: { id: true, name: true, email: true }, where: { name: 'Jane' }, order: { createdAt: 'DESC' }, take: 10,});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.
Querying: Relations
Section titled “Querying: Relations”const results = await db.query.users.findMany({ columns: { id: true, name: true }, with: { posts: { columns: { title: true }, where: (post, { eq }) => eq(post.published, true), }, },});const results = await em.find(User, {}, { fields: ['id', 'name', 'posts.title'], populate: ['posts'], populateFilter: { posts: { published: true } },});const results = await prisma.user.findMany({ select: { id: true, name: true, posts: { select: { title: true }, where: { published: true } }, },});// 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();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.
Aggregation & Grouping
Section titled “Aggregation & Grouping”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);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');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,});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();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.
Virtual Fields
Section titled “Virtual Fields”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}`, ),});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 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}`}));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;}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.
Mutations
Section titled “Mutations”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));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 });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 } });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);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.
Soft Delete
Section titled “Soft Delete”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));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.// 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 } });@Entity()export class User { @PrimaryGeneratedColumn() id: number; @DeleteDateColumn() deletedAt: Date;}// Native support for soft-deletion and automatic filteringawait manager.softDelete(User, id);@Entity()export class User { @Id({ type: Number }) id: number; @Field({ type: Date, softDelete: true }) deletedAt: Date;}// Marking the field enables global soft-deletion behaviorawait pool.deleteOneById(User, id); // soft deleteawait pool.restoreOneById(User, id); // bring it backawait pool.deleteOneById(User, id, { hardDelete: true }); // remove for goodIn 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.
Filtering: Comparison Operators
Section titled “Filtering: Comparison Operators”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']), ),);const results = await em.find(User, { age: { $gte: 18, $lte: 65 }, name: { $ilike: 'A%' }, status: { $nin: ['banned', 'inactive'] },});const results = await prisma.user.findMany({ where: { age: { gte: 18, lte: 65 }, name: { startsWith: 'A', mode: 'insensitive' }, status: { notIn: ['banned', 'inactive'] }, },});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'])),});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.
Network Boundaries & APIs
Section titled “Network Boundaries & APIs”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);});const query = { status: 'active' };const results = await em.find(User, query);const results = await prisma.user.findMany({ where: { status: 'active' } });app.get('/api/users', async (req, res) => { const results = await manager.find(User, { where: { id: req.query.id } }); res.json(results);});// Backend: auto-generated REST API for your entitiesimport { 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.
Migrations & Synchronization
Section titled “Migrations & Synchronization”// 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 databasenpx drizzle-kit generate // dialect comes from drizzle.config.tsnpx drizzle-kit push// 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 fileawait orm.getMigrator().createMigration();await orm.getMigrator().up();// 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 itnpx prisma migrate dev --name add_nickname// 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 DBnpx typeorm migration:generate -d ./data-source.ts ./migrations/AddNickname// 1. You edit your entity class// 2. UQL diffs YOUR CODE directly against the live database// 3. It auto-generates a clean, timestamped DDL migrationnpx uql-migrate generate:entities add_nicknamenpx uql-migrate upIn 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).
Streaming
Section titled “Streaming”// `.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);}const stream = await em.stream(User, { status: 'active' });for await (const user of stream) { await writeToCsv(user);}// Natively, this requires manual cursor paginationlet 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;}// 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));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.
Feature Matrix
Section titled “Feature Matrix”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 Support
Section titled “Database Support”| 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.
Install Footprint
Section titled “Install Footprint”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.
Benchmark
Section titled “Benchmark”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.