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.
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.
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;}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.
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,});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.
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,});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.
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 } }, },});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.
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,});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.
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;}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.
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);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.
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 goodFiltering 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.
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'] }, },});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.
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' } });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.
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 upUQL 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).
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);}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.
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 | ❌ | ✅ | ✅ | ✅ | ✅ |
| 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 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, 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.
Benchmark
Section titled “Benchmark”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.