Skip to content

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.


The starting point for any project. How you model your data determines your daily workflow and IDE experience.

Drizzle
import { pgTable, serial, varchar, integer } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).unique(),
name: varchar('name', { length: 255 }),
companyId: integer('company_id').references(() => companies.id),
});
MikroORM
import { 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;
}
Prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
companyId Int
company Company @relation(fields: [companyId], references: [id])
}
TypeORM
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm';
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@Column({ unique: true }) email: string;
@Column() name: string;
@ManyToOne(() => Company) company: Company;
}
UQL
import { Entity, Id, Field } 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.


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.

Drizzle
import { sql } from 'drizzle-orm';
const results = await db.select()
.from(items)
.orderBy(sql`embedding <-> ${queryVector}::vector`)
.limit(10);
MikroORM
const results = await em.createQueryBuilder(Item)
.select('*')
.orderBy({ ['embedding <-> ?']: [queryVector] })
.limit(10)
.getResult();
Prisma
const results = await prisma.$queryRaw`
SELECT * FROM "Item"
ORDER BY embedding <-> ${queryVector}::vector
LIMIT 10
`;
TypeORM
const results = await repo.createQueryBuilder('item')
.orderBy('item.embedding <-> :vector')
.setParameter('vector', queryVector)
.limit(10)
.getMany();
UQL
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.

AbilityDrizzleMikroORMPrismaUQL
Native Vector Operator
Multi-Dialect Support🔌🔌
Index Migration
JSON Path Vectors

Traditional querying still forms the backbone of any application. The goal is making complex filters readable and easily serializable for the network.

Drizzle
import { eq, desc } from 'drizzle-orm';
const results = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.where(eq(users.name, 'Lorem'))
.orderBy(desc(users.createdAt))
.limit(10);
MikroORM
const results = await em.find(User, { name: 'Lorem' }, {
fields: ['id', 'name', 'email'],
orderBy: { createdAt: 'DESC' },
limit: 10,
});
Prisma
const results = await prisma.user.findMany({
select: { id: true, name: true, email: true },
where: { name: 'Lorem' },
orderBy: { createdAt: 'desc' },
take: 10,
});
TypeORM
const results = await repo.find({
select: { id: true, name: true, email: true },
where: { name: 'Lorem' },
order: { createdAt: 'DESC' },
take: 10,
});
UQL
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.


Modern apps are deep. Managing nested data without “N+1” performance issues is essential for a smooth user experience.

Drizzle
const results = await db.query.users.findMany({
columns: { id: true, name: true },
with: { company: { columns: { name: true } } },
});
MikroORM
const results = await em.find(User, {
company: { country: 'US' }
}, {
fields: ['id', 'name', 'company.name'],
populate: ['company'],
});
Prisma
const results = await prisma.user.findMany({
select: {
id: true,
name: true,
company: {
select: { name: true },
where: { country: 'US' },
},
},
});
TypeORM
const results = await repo.find({
select: { id: true, name: true },
relations: { company: true },
});
UQL
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.


Defining calculated fields that aren’t stored in the database. These are a lifesaver for computed UI data (like full names) or derived counts.

Drizzle
const results = await db.select({
id: users.id,
fullName: sql<string>`concat(${users.firstName}, ' ', ${users.lastName})`
}).from(users);
MikroORM
@Entity()
class User {
@Property() firstName: string;
@Property() lastName: string;
@Property({
persist: false,
formula: alias => `concat(${alias}.firstName, ' ', ${alias}.lastName)`
})
fullName?: string;
}
Prisma
// Prisma doesn't support computed fields in the schema,
// so you usually map the results manually in your code:
const users = await prisma.user.findMany();
const results = users.map(u => ({
...u,
fullName: `${u.firstName} ${u.lastName}`
}));
TypeORM
import { Entity, Column, VirtualColumn, AfterLoad } from 'typeorm';
@Entity()
class User {
@Column() firstName: string;
@Column() lastName: string;
// SQL-based virtual field (Added in v0.3.x)
@VirtualColumn({
query: (alias) => `SELECT CONCAT(${alias}.firstName, ' ', ${alias}.lastName)`
})
fullName: string;
}
UQL
@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.


The bread and butter of your API. The goal here is clarity: “Did it work, and what’s the ID?”

Drizzle
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));
MikroORM
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 });
Prisma
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 } });
TypeORM
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);
UQL
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.


Safely “removing” data while keeping it in the database for recovery or audit trails.

Drizzle
// No built-in soft delete. Requires manual filter management:
await db.update(users).set({ deletedAt: new Date() }).where(eq(users.id, 1));
const results = await db.select().from(users).where(isNull(users.deletedAt));
MikroORM
@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
Prisma
// No built-in soft delete. Requires manual field management:
await prisma.user.update({ where: { id: 1 }, data: { deletedAt: new Date() } });
const results = await prisma.user.findMany({ where: { deletedAt: null } });
TypeORM
@Entity()
export class User {
@PrimaryGeneratedColumn() id: number;
@DeleteDateColumn() deletedAt: Date;
}
// Native support for soft-deletion and automatic filtering
await repo.softDelete(id);
UQL
@Entity({ softDelete: true })
export class User {
@Id() id: number;
@Field({ onDelete: 'soft' }) deletedAt: Date;
}
// Native decorator enables global soft-deletion behavior
await 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 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.

Drizzle
import { gte, lte, ilike, notInArray, and } from 'drizzle-orm';
const results = await db.select().from(users).where(
and(
gte(users.age, 18),
lte(users.age, 65),
ilike(users.name, 'A%'),
notInArray(users.status, ['banned', 'inactive']),
),
);
MikroORM
const results = await em.find(User, {
age: { $gte: 18, $lte: 65 },
name: { $like: 'A%' },
status: { $nin: ['banned', 'inactive'] },
});
Prisma
const results = await prisma.user.findMany({
where: {
age: { gte: 18, lte: 65 },
name: { startsWith: 'A', mode: 'insensitive' },
status: { notIn: ['banned', 'inactive'] },
},
});
TypeORM
import { Between, ILike, Not, In } from 'typeorm';
const results = await repo.findBy({
age: Between(18, 65),
name: ILike('A%'),
status: Not(In(['banned', 'inactive'])),
});
UQL
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.


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.

Drizzle
app.get('/api/users', async (req, res) => {
const results = await db.select().from(users).where(eq(users.id, req.query.id));
res.json(results);
});
MikroORM
const query = { status: 'active' };
const results = await em.find(User, query);
Prisma
const results = await prisma.user.findMany({ where: { status: 'active' } });
TypeORM
app.get('/api/users', async (req, res) => {
const results = await repo.find({ where: { id: req.query.id } });
res.json(results);
});
UQL
import { querierMiddleware } from 'uql-orm/express';
// Backend
app.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.


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.

Drizzle
// 1. You edit your TS schema
// 2. You run a CLI command to generate a JSON "snapshot"
// 3. You run another command to generate a SQL migration from that snapshot
// 4. Finally, you apply the SQL to your database
npx drizzle-kit generate:pg
npx drizzle-kit push:pg
MikroORM
// 1. You edit your entities
// 2. MikroORM diffs your metadata against the live DB (or a schema dump)
// 3. It generates a TS/JS migration file
await orm.getMigrator().createMigration();
await orm.getMigrator().up();
Prisma
// 1. You edit the proprietary .prisma file
// 2. You run a 'dev' command which requires a "Shadow Database" to diff
// 3. Prisma generates a SQL file and applies it
npx prisma migrate dev --name add_nickname
TypeORM
// 1. You edit your entities
// 2. TypeORM can auto-sync in dev (dangerous for production)
// 3. Or you manually generate a migration by diffing against a live DB
npx typeorm migration:generate -n AddNickname
UQL
// 1. You edit your entity class
// 2. UQL diffs YOUR CODE directly against the live database
// 3. It auto-generates a clean, timestamped DDL migration
npx uql-migrate generate:entities add_nickname
npx uql-migrate up

Developer 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).


Iterating through massive result sets without causing “Out of Memory” (OOM) errors. This is crucial for reports, CSV exports, or AI indexing jobs.

Drizzle
const stream = await db.select().from(users).iterator();
for await (const user of stream) {
await writeToCsv(user);
}
MikroORM
const stream = await em.stream(User, { status: 'active' });
for await (const user of stream) {
await writeToCsv(user);
}
Prisma
// Natively, this requires manual cursor pagination
let cursor: number | undefined;
while (true) {
const batch = await prisma.user.findMany({
take: 100, skip: cursor ? 1 : 0, cursor: cursor ? { id: cursor } : undefined
});
if (batch.length === 0) break;
for (const user of batch) await writeToCsv(user);
cursor = batch[batch.length - 1].id;
}
TypeORM
const results = await repo.createQueryBuilder('user').stream();
results.on('data', (user) => writeToCsv(user));
UQL
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.


Features marked as: ✅ native, 🔌 via extension/plugin, ❌ not available.

CapabilityDrizzleMikroORMPrismaTypeORMUQL
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.


DatabaseDrizzleMikroORMPrismaTypeORMUQL
Cloudflare D1
CockroachDB
LibSQL / Turso
MariaDB
MongoDB❌²
MSSQL
MySQL
Neon Serverless
PostgreSQL
SQLite

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.

OperationNext FastestUQLAdvantage
SELECT (complex)Kysely (218K)644K ops/s3.0x
SELECT (1 field)Sequelize (3,143K)3,994K ops/s1.3x
INSERT (10 rows)Knex (407K)606K ops/s1.5x
UPDATEKysely (819K)1,817K ops/s2.2x
UPSERTKnex (349K)693K ops/s2.0x
DELETESequelize (1,349K)3,642K ops/s2.7x
SELECT + SORT + LIMITKnex (480K)1,200K ops/s2.5x
AGGREGATESequelize (407K)1,489K ops/s3.7x

There is no universal winner here. Pick based on your team’s workflow, not just features.

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.

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 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.

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 .prisma DSL.
  • You want to avoid generated client code and generation steps in CI/build.
  • You need native AsyncIterable streaming instead of manual cursor pagination.
  • You rely heavily on driver-specific SQL and want first-class SQL composition.

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.