ORM Comparison
Technical Profile: ORM Comparison
Section titled “Technical Profile: ORM Comparison”Disclosure: This comparison is maintained by the UQL team. We include source links and integrate community corrections to keep claims verifiable.
This page provides an operation-by-operation code comparison between the leading TypeScript ORMs. The goal is to show the actual API differences—not just checkmarks in a table—so you can choose the right tool for your specific architecture.
Community Updates (Mar, 2026) - from this Reddit thread
Section titled “Community Updates (Mar, 2026) - from this Reddit thread”- Added Joist as a full participant across all comparison sections.
- Updated MikroORM to v7-recommended
defineEntitystyle in schema examples. - Updated database matrix: MikroORM Cloudflare D1 (experimental/plugin path), MikroORM Neon support, and Oracle coverage.
- Added runtime-model comparison row: managed entities that require active ORM context (Unit of Work / Identity Map).
Schema Definition
Section titled “Schema Definition”The starting point for any project. How you model your data determines your daily workflow and IDE experience.
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 } from 'uql-orm';
@Entity()export class User { @Id() id?: number; @Field({ unique: true }) email?: string; @Field() name?: string; @Field({ references: () => Company }) companyId?: number;}// 1. Database-first: define schema in a migration (e.g. node-pg-migrate)// 2. Run joist-codegen — entities are auto-generated from the live DB
// Generated (auto-managed): src/entities/UserCodegen.tsexport abstract class UserCodegen { readonly company: ManyToOneReference<User, Company, never>; get id(): string { /* ... */ } get email(): string { /* ... */ } set email(value: string) { /* ... */ } get name(): string { /* ... */ } set name(value: string) { /* ... */ }}
// Your file: src/entities/User.tsexport class User extends UserCodegen {}Senior Insight: Schema modeling is a trade-off: Prisma offers a clean DSL with a build step; Drizzle gives total SQL control via dialect-specific imports. MikroORM v7 defaults to defineEntity (a programmatic, decorator-free style), TypeORM and UQL use decorator-based TypeScript classes, all keeping the model in pure TS. Joist takes a database-first approach: you write migrations, then codegen produces the entity classes automatically.
Semantic Search
Section titled “Semantic Search”AI and RAG (Retrieval-Augmented Generation) have made vector similarity search a mainstream requirement. The goal is ranking results by meaning, not just keywords, without losing the type-safe experience we love in ORMs.
import { sql } from 'drizzle-orm';
const results = await db.select() .from(items) .orderBy(sql`embedding <-> ${queryVector}::vector`) .limit(10);const results = await em.createQueryBuilder(Item) .select('*') .orderBy({ ['embedding <-> ?']: [queryVector] }) .limit(10) .getResult();const results = await prisma.$queryRaw` SELECT * FROM "Item" ORDER BY embedding <-> ${queryVector}::vector LIMIT 10`;const results = await repo.createQueryBuilder('item') .orderBy('item.embedding <-> :vector') .setParameter('vector', queryVector) .limit(10) .getMany();const results = await querier.findMany(Item, { $select: { id: true, title: true }, $sort: { $vector: { embedding: queryVector } }, $limit: 10,});// No native vector operator — requires raw SQL via Knex:const results = await knex .select('*') .from('items') .orderByRaw('embedding <-> ?::vector', [queryVector]) .limit(10);Senior Insight: Most ORMs treat vector similarity as an “edge case” requiring raw SQL and database-specific syntax (like <->). UQL handles it as a native, type-safe operator, allowing you to build AI features with the same database-agnostic code used for standard sorting.
| Ability | Drizzle | MikroORM | Prisma | TypeORM | Joist | UQL |
|---|---|---|---|---|---|---|
| Native Vector Operator | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Multi-Dialect Support | 🔌 | ❌ | 🔌 | 🔌 | ❌ | ✅ |
| Index Migration | ❌ | ❌ | ✅ | ❌ | ❌ | ✅ |
| JSON Path Vectors | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
Find — Selection & Filtering
Section titled “Find — Selection & Filtering”Traditional querying still forms the backbone of any application. The goal is making complex filters readable and easily serializable for the network.
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, 'Lorem')) .orderBy(desc(users.createdAt)) .limit(10);const results = await em.find(User, { name: 'Lorem' }, { fields: ['id', 'name', 'email'], orderBy: { createdAt: 'DESC' }, limit: 10,});const results = await prisma.user.findMany({ select: { id: true, name: true, email: true }, where: { name: 'Lorem' }, orderBy: { createdAt: 'desc' }, take: 10,});const results = await repo.find({ select: { id: true, name: true, email: true }, where: { name: 'Lorem' }, order: { createdAt: 'DESC' }, take: 10,});const results = await querier.findMany(User, { $select: { id: true, name: true, email: true }, $where: { name: 'Lorem' }, $sort: { createdAt: 'desc' }, $limit: 10,});const results = await em.find(User, { name: 'Lorem' }, { orderBy: { createdAt: 'DESC' },});// Joist loads full entities (identity-map) — no field-level selectSenior Insight: 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. Joist uses a similar object-literal style but always loads full entities due to its identity-map architecture.
Querying — Relations
Section titled “Querying — Relations”Modern apps are deep. Managing nested data without “N+1” performance issues is essential for a smooth user experience.
const results = await db.query.users.findMany({ columns: { id: true, name: true }, with: { company: { columns: { name: true } } },});const results = await em.find(User, { company: { country: 'US' }}, { fields: ['id', 'name', 'company.name'], populate: ['company'],});const results = await prisma.user.findMany({ select: { id: true, name: true, company: { select: { name: true }, where: { country: 'US' }, }, },});const results = await repo.find({ select: { id: true, name: true }, relations: { company: true },});const results = await querier.findMany(User, { $select: { id: true, name: true, company: { $select: { name: true }, $where: { country: 'US' } }, },});const results = await em.find(User, { company: { country: 'US' },});// Relations loaded via populate hints, N+1 safe by defaultconst loaded = await results[0].populate({ company: {} });console.log(loaded.company.get.name);Senior Insight: Relation filtering varies significantly. Prisma, MikroORM, UQL, and Joist allow you to nest logic (like $where: { country: 'US' }) directly inside the fetch. Drizzle and TypeORM often require the QueryBuilder for advanced relation filtering. Joist’s “join literal” approach is unique: it builds JOINs from nested object shapes and is N+1 safe by default thanks to its dataloader foundation.
Virtual Fields
Section titled “Virtual Fields”Defining calculated fields that aren’t stored in the database. These are a lifesaver for computed UI data (like full names) or derived counts.
const results = await db.select({ id: users.id, fullName: sql<string>`CONCAT(${users.firstName}, ' ', ${users.lastName})`}).from(users);import { defineEntity, p, quote } from '@mikro-orm/core';
const UserSchema = defineEntity({ name: 'User', properties: { firstName: p.string(), lastName: p.string(), fullName: p.formula<string>(cols => quote`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;}@Entity()class User { @Field() firstName: string; @Field() lastName: string;
@Field({ virtual: raw(({ escapedPrefix }) => `CONCAT(${escapedPrefix}.firstName, ' ', ${escapedPrefix}.lastName)`) }) fullName?: string;}class User extends UserCodegen { // Sync getter — not stored in DB, calculated on access get fullName(): string { return `${this.firstName} ${this.lastName}`; }}
// Or as a ReactiveField — stored in DB and auto-updated on change:class User extends UserCodegen { readonly fullName: ReactiveField<User, string> = hasReactiveField( "fullName", ["firstName", "lastName"], (u) => `${u.firstName} ${u.lastName}`, );}Senior Insight: Manually mapping computed fields is brittle. MikroORM and UQL allow you to define virtual fields directly in the entity, where they behave like real columns for sorting and filtering. Drizzle and Prisma require manual mapping at the app level. Joist goes further with ReactiveFields: derived values that are persisted to the database and automatically recalculated when their dependencies change—essentially instantly-updated materialized views.
Mutations
Section titled “Mutations”The bread and butter of your API. The goal here is clarity: “Did it work, and what’s the ID?”
const [user] = await db.insert(users).values({ name: 'Lorem' }).returning();
await db.update(users).set({ name: 'Lorem I.' }).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: 'Lorem' });await em.flush();
await em.nativeUpdate(User, { id: user.id }, { name: 'Lorem I.' });
await em.nativeUpdate(User, { status: 'inactive' }, { status: 'archived' });
await em.nativeDelete(User, { id: user.id });const user = await prisma.user.create({ data: { name: 'Lorem' } });
await prisma.user.update({ where: { id: user.id }, data: { name: 'Lorem I.' } });
await prisma.user.updateMany({ where: { status: 'inactive' }, data: { status: 'archived' } });
await prisma.user.delete({ where: { id: user.id } });const user = repo.create({ name: 'Lorem' });await repo.save(user);
await repo.update(user.id, { name: 'Lorem I.' });
await repo.update({ status: 'inactive' }, { status: 'archived' });
await repo.delete(user.id);const id = await querier.insertOne(User, { name: 'Lorem' });
await querier.updateOneById(User, id, { name: 'Lorem I.' });
await querier.updateMany(User, { $where: { status: 'inactive' } }, { status: 'archived' });
await querier.deleteOneById(User, id);const user = em.create(User, { name: 'Lorem' });await em.flush();
user.name = 'Lorem I.';await em.flush();
// Bulk conditional updates require Knex or manual find + loop + flush
em.delete(user);await em.flush();Senior Insight: Most CRUD work 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. Joist follows the Unit of Work pattern like MikroORM: mutations are staged in the EntityManager and flushed atomically, with automatic batch INSERTs/UPDATEs.
Soft Delete
Section titled “Soft Delete”Safely “removing” data while keeping it in the database for recovery or audit trails.
// 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: { 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 repo.softDelete(id);@Entity({ softDelete: true })export class User { @Id() id: number; @Field({ onDelete: 'soft' }) deletedAt: Date;}// Native decorator enables global soft-deletion behaviorawait querier.deleteOneById(User, id);// Add a deleted_at column to your migration — Joist handles the rest.// em.find automatically filters out soft-deleted rows.em.delete(user);await em.flush();
// To explicitly include soft-deleted rows:const all = await em.find(User, {}, { softDeletes: 'include' });Senior Insight: Hand-filtering deletedAt: null in every query is a bug magnet. TypeORM, MikroORM, UQL, and Joist handle this at the engine level, ensuring deleted records are hidden automatically without manual discipline.
Filtering — Comparison Operators
Section titled “Filtering — Comparison Operators”Filtering data shouldn’t require a manual or a degree in query DSLs. The most natural operators are the ones you can easily read and, ideally, serialize without extra work.
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: { $like: '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 repo.findBy({ age: Between(18, 65), name: ILike('A%'), status: Not(In(['banned', 'inactive'])),});const results = await querier.findMany(User, { $where: { age: { $gte: 18, $lte: 65 }, name: { $istartsWith: 'A' }, status: { $nin: ['banned', 'inactive'] }, },});const results = await em.find(User, { age: { gte: 18, lte: 65 }, name: { like: 'A%' }, status: { ne: 'banned' },});
// For complex OR conditions, use aliases:const [u] = aliases(User);await em.find(User, { as: u }, { conditions: { or: [u.age.gte(18), u.status.eq('active')] },});
// Note: Joist does not support filtering on JSONB columns in em.find;// for those you need raw SQL/Knex via em.loadFromQuery.Senior Insight: Object-based filtering (Prisma, MikroORM, UQL, Joist) is often more ergonomic than function-based filtering (Drizzle, TypeORM), as it avoids importing dozens of individual operators and is natively serializable as JSON. Joist adds a twist: for cross-column OR conditions, you use typed aliases instead of raw objects. Note that Joist does not yet support JSONB column filtering in em.find — you need to drop to raw SQL for that.
JSON / JSONB Operators (Practical Coverage)
Section titled “JSON / JSONB Operators (Practical Coverage)”For teams building search-heavy or settings-heavy apps, JSON operator depth matters as much as plain scalar filtering.
UQL currently provides the broadest cross-dialect JSON operator surface in this comparison. The focus is practical parity across PostgreSQL, MySQL, MariaDB, and SQLite using each database’s native JSON functions/operators on modern maintained releases.
| JSON capability | Drizzle | Joist | 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 5 SQL dialects | ❌ | ❌ | ✅⁴ | ❌ | ❌ | ✅ |
¹ Requires raw SQL.
² Achievable with dialect-specific SQL expressions or query-builder escape hatches, not a unified high-level JSON operator API.
³ Prisma advanced JSON filtering is available on selected connectors and has connector-specific limitations.
⁴ 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.
Snapshot references (verified Mar, 2026):
- Drizzle JSON operator gap: issue #1690
- TypeORM JSON containment scope: issue #11800
- Prisma JSON filtering docs: Working with Json fields
See JSON / JSONB for generated SQL examples and practical baseline dialect versions.
Network Boundaries & APIs
Section titled “Network Boundaries & APIs”In a world of microservices and fullstack apps, your data shouldn’t be trapped on the server. The “mental context switch” of mapping database results to JSON API responses is a significant source of developer toil.
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 repo.find({ where: { id: req.query.id } }); res.json(results);});import { querierMiddleware } from 'uql-orm/express';
// Backendapp.use('/api', querierMiddleware({ include: [User] }));
// Frontend (Client-side)import { HttpQuerier } from 'uql-orm/browser';
const http = new HttpQuerier('/api');const results = await http.findMany(User, { $where: { status: 'active' } });// No built-in REST API or browser querier — standard manual routing:app.get('/api/users', async (req, res) => { const em = newEntityManager(); const results = await em.find(User, { status: 'active' }); res.json(results);});Senior Insight: Building API boilerplate for every model is pure toil. In this comparison, every ORM except UQL requires you to build your own API bridge, while UQL provides first-party HttpQuerier transport between browser and database.
Migrations & Synchronization
Section titled “Migrations & Synchronization”Keeping your database schema in sync with your code is one of the most stressful parts of development. The goal is “Zero Drift”—ensuring what’s in your TypeScript classes is exactly what’s in your production database, without manual SQL headaches.
// 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:pgnpx drizzle-kit push:pg// 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 -n 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 up// 1. Database-first: you write migrations (e.g. node-pg-migrate)// 2. Run joist-codegen to regenerate entity classes from the live DB schemanpm run joist-migratenpm run joist-codegenSenior Insight: 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). Joist takes the opposite Database-First approach: your migration files are the source of truth, and codegen produces entities to match.
Streaming
Section titled “Streaming”Iterating through massive result sets without causing “Out of Memory” (OOM) errors. This is crucial for reports, CSV exports, or AI indexing jobs.
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;}const results = await repo.createQueryBuilder('user').stream();results.on('data', (user) => writeToCsv(user));const results = await querier.findManyStream(User, { $where: { status: 'active' } });for await (const user of results) { await writeToCsv(user);}// No built-in streaming — defers to Knex for large result sets:const stream = knex.select('*').from('users').where('status', 'active').stream();stream.on('data', (user) => writeToCsv(user));Senior Insight: Processing millions of rows requires native cursors to keep memory stable. MikroORM and UQL provide consistent AsyncIterable support across all drivers—including MongoDB—replacing complex event-emitters with clean for await loops. Joist defers to its underlying Knex connection for streaming.
Feature Matrix
Section titled “Feature Matrix”Features marked as: ✅ native, 🔌 via extension/plugin, ❌ not available.
| Capability | Drizzle | Joist | 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) | ❌ | ✅ | 🔌 | 🔌 | ✅ | ✅ |
| Lifecycle hooks | ❌ | ✅ | ✅ | 🔌 | ✅ | ✅ |
| Managed entities require active ORM context (Unit of Work / Identity Map) | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ |
| N+1 prevention (automatic) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Reactive fields (auto-updated) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Test factories (built-in) | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Tagged ids | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ |
| Auto REST API | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| Browser querier | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ |
| MongoDB support | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ |
| Unified query mental model across SQL + MongoDB | ❌ | ❌ | ✅ | 🔌⁴ | ❌⁵ | ✅ |
⁴ 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).
Database Support
Section titled “Database Support”| Database | Drizzle | Joist | 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.
Performance
Section titled “Performance”In our open-source benchmark measuring pure SQL generation speed (no database I/O).
You can explore the charts here: TypeScript ORM Benchmark Charts.
Across these benchmark operations (pure SQL generation, no database I/O), UQL is on average ~2.4x faster than the next fastest tool.
| Operation group | Avg advantage (vs. next fastest) |
|---|---|
Reads (SELECT*, sort/limit, AGGREGATE) | ~2.6x |
Writes (INSERT/UPDATE/UPSERT/DELETE) | ~2.1x |
Reads (SELECT-heavy)
Section titled “Reads (SELECT-heavy)”| Operation | Next fastest | UQL | Advantage |
|---|---|---|---|
| SELECT (complex) | Kysely (218K) | 644K ops/s | 3.0x |
| SELECT (1 field) | Sequelize (3,143K) | 3,994K ops/s | 1.3x |
| SELECT + SORT + LIMIT | Knex (480K) | 1,200K ops/s | 2.5x |
| AGGREGATE | Sequelize (407K) | 1,489K ops/s | 3.7x |
Writes (mutations)
Section titled “Writes (mutations)”| Operation | Next fastest | UQL | Advantage |
|---|---|---|---|
| INSERT (10 rows) | Knex (407K) | 606K ops/s | 1.5x |
| UPDATE | Kysely (819K) | 1,817K ops/s | 2.2x |
| UPSERT | Knex (349K) | 693K ops/s | 2.0x |
| DELETE | Sequelize (1,349K) | 3,642K ops/s | 2.7x |
Summary: Choosing the Right Tool
Section titled “Summary: Choosing the Right Tool”There is no universal winner here. Pick based on your team’s workflow, not just features.
Drizzle
Section titled “Drizzle”When to use Drizzle
- You like staying close to SQL and keeping abstraction layers thin.
- You care about explicit query composition and tight control over generated SQL.
- You want a lightweight runtime with strong TypeScript ergonomics.
- You are targeting modern SQL deployments, including edge-oriented setups.
When not to use Drizzle
- Day-to-day SQL-heavy development feels like overhead for your team.
- You need richer ORM patterns such as unit-of-work and identity-map behavior.
- You expect built-in soft delete behavior instead of manual filtering discipline.
- You want first-party browser/server query transport primitives.
Kysely (Query Builder)
Section titled “Kysely (Query Builder)”When to use Kysely
- You want pure, type-safe SQL composition.
- You prefer explicit query control over ORM entity abstractions.
- You want strong type safety while staying close to SQL concepts.
- You do not need identity-map behavior, relation APIs, or ORM lifecycle hooks.
When not to use Kysely
- You need classic ORM features like entities, relations, and lifecycle hooks.
- Your domain model depends on unit-of-work and identity-map patterns.
- You expect metadata-driven ORM automation.
- You want first-party fullstack query transport primitives out of the box.
When to use Joist
- You want bullet-proof automatic N+1 prevention without manual optimization.
- ReactiveFields (auto-updated derived columns) are a core part of your domain model.
- You prefer database-first schema management with generated, type-safe entities.
- Built-in test factories, tagged IDs, and reactive validation rules match your workflow.
When not to use Joist
- You need a database other than PostgreSQL.
- You want to avoid a codegen step in your build pipeline.
- You need native streaming, vector search, or browser-to-server query transport.
- You prefer entity-first schema modeling with detached/plain results, not ORM-managed hydrated entities.
MikroORM
Section titled “MikroORM”When to use MikroORM
- Your domain layer benefits from unit-of-work and identity-map patterns.
- You want entity-first modeling with strong relation handling.
- You prefer a consistent TypeScript-first architecture.
- You need one ORM across SQL and MongoDB with similar mental models.
When not to use MikroORM
- You do not need Unit of Work/Identity Map complexity and prefer detached/plain results.
- Ecosystem size and hiring familiarity are top priorities.
- You prefer a direct SQL/query-builder style over richer ORM abstractions.
- You need first-party auto-REST or browser-querier primitives.
Prisma
Section titled “Prisma”When to use Prisma
- You want a full product experience: mature ecosystem, Prisma Studio, and managed add-ons.
- You prefer a predictable migration workflow centered on Prisma Migrate.
- Your team values a polished DX with strong docs and conventions.
- You need broad SQL support with a stable day-to-day workflow.
When not to use Prisma
- You do not want to learn or maintain a separate
.prismaDSL. - You want to avoid generated client code and generation steps in CI/build.
- You need native
AsyncIterablestreaming instead of manual cursor pagination. - You rely heavily on driver-specific SQL and want first-class SQL composition.
TypeORM
Section titled “TypeORM”When to use TypeORM
- You want a long-established ORM with wide database support.
- Your team is comfortable with decorators, entities, and repository patterns.
- You need lifecycle hooks and traditional enterprise ORM capabilities.
- You come from Java/C# ORM mental models and want familiar patterns.
When not to use TypeORM
- You want a modern JSON-serializable query object style by default.
- You need stronger first-party support for semantic/vector workflows.
- You prefer a smaller, stricter API surface with fewer legacy patterns.
- You want built-in browser-to-server querying bridges.
When to use UQL
- A JSON-query model that works across backend and browser boundaries is important to you.
- You want lean runtime with strong edge/runtime compatibility as a core requirement.
- You want entity-first migrations and high SQL generation performance.
- You are building AI/RAG-heavy apps and want native support from your ORM.
When not to use UQL
- You need MSSQL support today.
- You prioritize ecosystem maturity and third-party integrations over newer architecture.
- You require tooling like Prisma Studio as a core workflow.
- You need maximum community mindshare for immediate hiring and onboarding.