ORM Comparison
Technical Profile: ORM Comparison
Section titled “Technical Profile: ORM Comparison”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.
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 { Entity, PrimaryKey, Property, ManyToOne } from '@mikro-orm/core';
@Entity()export class User { @PrimaryKey() id!: number; @Property({ unique: true }) email!: string; @Property() name!: string; @ManyToOne(() => Company) company!: Company;}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;}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, TypeORM, and UQL use standard TypeScript classes, keeping your mental model unified in pure TS.
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,});Developer 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 | 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,});Developer 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.
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' } }, },});Developer Insight: Relation filtering varies significantly. Prisma, MikroORM, and UQL allow you to nest logic (like $where: { country: 'US' }) directly inside the fetch. Drizzle and TypeORM often require the QueryBuilder for advanced relation filtering.
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);@Entity()class User { @Property() firstName: string; @Property() lastName: string;
@Property({ persist: false, formula: alias => `concat(${alias}.firstName, ' ', ${alias}.lastName)` }) fullName?: string;}// 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;}Developer 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.
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);Developer 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.
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));@Entity()@Filter({ name: 'softDelete', cond: { deletedAt: null }, default: true })export class User { @PrimaryKey() id!: number; @Property({ nullable: true }) deletedAt?: Date;}// Automatically filters out records where deletedAt is set// 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);Developer Insight: Hand-filtering deletedAt: null in every query is a bug magnet. TypeORM, MikroORM, and UQL 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'] }, },});Developer Insight: Object-based filtering (Prisma, MikroORM, UQL) is often more ergonomic than function-based filtering (Drizzle, TypeORM), as it avoids importing dozens of individual operators and is natively serializable as JSON.
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' } });Developer Insight: Building API boilerplate for every model is pure toil. While Drizzle and TypeORM require you to build a manual bridge, UQL provides the first-party HttpQuerier to automate the bridge 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 upDeveloper 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).
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);}Developer 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.
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) | ❌ | 🔌 | 🔌 | ✅ | ✅ |
| Lifecycle hooks | ❌ | ✅ | 🔌 | ✅ | ✅ |
| Auto REST API | ❌ | ❌ | ❌ | ❌ | ✅ |
| Browser querier | ❌ | ❌ | ❌ | ❌ | ✅ |
| MongoDB support | ❌ | ✅ | ❌² | ✅ | ✅ |
¹ Prisma streaming is natively limited; requires cursor pagination for large sets. ² Prisma v6/v7 support for MongoDB is constrained compared to SQL dialects.
Database Support
Section titled “Database Support”| Database | Drizzle | MikroORM | Prisma | TypeORM | UQL |
|---|---|---|---|---|---|
| Cloudflare D1 | ✅ | ❌ | ✅ | ❌ | ✅ |
| CockroachDB | ✅ | ✅ | ✅ | ✅ | ✅ |
| LibSQL / Turso | ✅ | ✅ | ✅ | ❌ | ✅ |
| MariaDB | ✅ | ✅ | ✅ | ✅ | ✅ |
| MongoDB | ❌ | ✅ | ❌² | ✅ | ✅ |
| MSSQL | ✅ | ✅ | ✅ | ✅ | ❌ |
| MySQL | ✅ | ✅ | ✅ | ✅ | ✅ |
| Neon Serverless | ✅ | ❌ | ✅ | ❌ | ✅ |
| PostgreSQL | ✅ | ✅ | ✅ | ✅ | ✅ |
| SQLite | ✅ | ✅ | ✅ | ✅ | ✅ |
Performance
Section titled “Performance”In our open-source benchmark measuring pure SQL generation speed (no database I/O), UQL finished 1st in every category across full ORMs and query builders alike.
| 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 |
| 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 |
| SELECT + SORT + LIMIT | Knex (480K) | 1,200K ops/s | 2.5x |
| AGGREGATE | Sequelize (407K) | 1,489K ops/s | 3.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.
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.
- 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.