> Every UQL docs page, as Markdown: https://uql-orm.dev/llms.txt
> The same docs over MCP: https://uql-orm.dev/mcp
> Before writing UQL code, read the skill: https://uql-orm.dev/.well-known/agent-skills/uql-orm/SKILL.md

# ORM Comparison by Feature

> Side-by-side API comparison of Drizzle vs MikroORM vs Prisma vs TypeORM vs UQL, with actual code for every common operation.

Source: https://uql-orm.dev/comparison

Every common operation, side by side for [Drizzle](https://orm.drizzle.team), [MikroORM](https://mikro-orm.io), [Prisma](https://www.prisma.io), [TypeORM](https://typeorm.io), and UQL. All the samples appear in alphabetical order.

Versions compared: Drizzle `0.45.2`, MikroORM `7.2.1`, Prisma `7.10.0`, TypeORM `1.1.1`, UQL `0.77.1`.

## Schema definition

```ts title="Drizzle"
// The most direct control over the SQL, through dialect-specific helpers.
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),
});
```

```ts title="MikroORM"
// v7 adds `defineEntity`; the decorators have moved to a separate package.
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);
```

```prisma title="Prisma"
// The shortest to read, but a language of its own with a build step behind it.
model User {
  id        Int     @id @default(autoincrement())
  email     String  @unique
  name      String?
  companyId Int
  company   Company @relation(fields: [companyId], references: [id])
}
```

```ts title="TypeORM"
// Needs `experimentalDecorators` and `emitDecoratorMetadata` in tsconfig, unless you
// use `EntitySchema`, which takes the same metadata as an object.
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;
}
```

```ts title="UQL"
// Standard TC39 decorators, so no compiler flags to turn on. Or no decorators at all
// with `defineEntity`, which registers the same metadata imperatively (/entities/imperative).
import { Entity, Id, Field, ManyToOne } from 'uql-orm';

@Entity()
export class User {
  @Id({ type: Number }) id?: number;
  @Field({ type: String, unique: true }) email?: string | null;
  @Field({ type: String }) name?: string | null;
  @Field({ type: Number, references: () => Company }) companyId?: number | null;
  @ManyToOne({ entity: () => Company, references: (user) => user.companyId })
  company?: Company;
}
```

Only Prisma leaves TypeScript to describe a schema. Everything else here is a file you already know how to read.

---

## Indexes

One email per tenant, counting only the rows that are not soft-deleted: a composite, unique, partial index.

```ts title="Drizzle"
// The columns are typed references; the predicate is an `sql` fragment over them.
import { sql } from 'drizzle-orm';
import {
  pgTable,
  serial,
  varchar,
  integer,
  timestamp,
  uniqueIndex,
} from 'drizzle-orm/pg-core';

export const users = pgTable(
  'users',
  {
    id: serial('id').primaryKey(),
    email: varchar('email', { length: 255 }),
    tenantId: integer('tenant_id'),
    deletedAt: timestamp('deleted_at'),
  },
  (t) => [
    uniqueIndex('users_email_tenant_idx')
      .on(t.email, t.tenantId)
      .where(sql`${t.deletedAt} IS NULL`),
  ],
);
```

```ts title="MikroORM"
// The property names are checked; the `where` object is not, so an unknown key or a
// string for a date compiles all the same.
import { defineEntity, p } from '@mikro-orm/core';

const UserSchema = defineEntity({
  name: 'User',
  properties: {
    id: p.integer().primary(),
    email: p.string(),
    tenantId: p.integer(),
    deletedAt: p.datetime().nullable(),
  },
  uniques: [{ properties: ['email', 'tenantId'], where: { deletedAt: null } }],
});
```

```prisma title="Prisma"
// `where` is behind the `partialIndexes` preview feature. The object form is validated
// against the fields' types, but takes only literals, `null` and `{ not }`; the rest is `raw("...")`.
model User {
  id        Int       @id @default(autoincrement())
  email     String
  tenantId  Int
  deletedAt DateTime?

  @@unique([email, tenantId], where: { deletedAt: null })
}
```

```ts title="TypeORM"
// The columns and the predicate are both strings, the predicate in the database's own SQL.
import { Entity, Index, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity()
@Index(['email', 'tenantId'], { unique: true, where: '"deletedAt" IS NULL' })
export class User {
  @PrimaryGeneratedColumn() id: number;
  @Column() email: string;
  @Column() tenantId: number;
  @Column({ nullable: true }) deletedAt: Date;
}
```

```ts title="UQL"
// The columns are members and the predicate is a `$where`, both checked against the
// entity, so `where: { deletedAt: 'yes' }` does not compile.
import { Entity, Id, Field, Index } from 'uql-orm';

@Index((user) => [user.email, user.tenantId], {
  unique: true,
  where: { deletedAt: null },
})
@Entity()
export class User {
  @Id({ type: Number }) id?: number;
  @Field({ type: String }) email?: string | null;
  @Field({ type: Number }) tenantId?: number | null;
  @Field({ type: Date, softDelete: true }) deletedAt?: Date | null;
}
```

Declaring the index has converged: all five take a composite, unique, partial one. What differs is how much of it gets checked. The predicate is raw SQL in Drizzle and TypeORM, an unchecked object in MikroORM, a validated but narrow object in Prisma, and a typed `$where` with every operator in UQL.

MySQL has no partial index at all. MikroORM emulates one with a functional index that is unique only where the predicate holds, Prisma refuses the `where`, and UQL throws when the migration is generated rather than widen the index to the whole table. Which of these declarations follow a rename is measured on [Rename Safety](https://uql-orm.dev/rename-safety.md), and the rest of what `@Index` takes is on [Indexes](https://uql-orm.dev/entities/indexes.md).

---

## Semantic search

```ts title="Drizzle"
// pgvector column types, five distance helpers, and an HNSW/IVFFlat index declared in
// the schema for `drizzle-kit` to emit. PostgreSQL only.
import { cosineDistance } from 'drizzle-orm';

const results = await db
  .select()
  .from(items)
  .orderBy(cosineDistance(items.embedding, queryVector))
  .limit(10);
```

```ts title="MikroORM"
// Same operators, from pgvector's own adapter rather than core. PostgreSQL only.
import { cosineDistance } from 'pgvector/mikro-orm';

const results = await em
  .createQueryBuilder(Item)
  .select('*')
  .orderBy({ [cosineDistance('embedding', queryVector)]: 'ASC' })
  .limit(10)
  .getResult();
```

```ts title="Prisma"
// No vector operator, so this stays as raw SQL for every specific DB.
const results = await prisma.$queryRaw`
  SELECT * FROM "Item"
  ORDER BY embedding <=> ${queryVector}::vector
  LIMIT 10
`;
```

```ts title="TypeORM"
// Maps a `vector` column on several engines, but no distance expression on any,
// so the ORDER BY is a hand-written string.
const results = await manager
  .createQueryBuilder(Item, 'item')
  .orderBy('item.embedding <=> :vector')
  .setParameter('vector', queryVector)
  .limit(10)
  .getMany();
```

```ts title="UQL"
// A typed operator (not a helper per DB); same query on PostgreSQL,
// CockroachDB, MariaDB, SQLite, libSQL/Turso, MSSQL, and MongoDB Atlas.
// `$near` bounds the distance, `$candidates` widens the index search.
const results = await pool.findMany(Item, {
  $select: { id: true, name: true },
  $where: { embedding: { $near: { $vector: queryVector, $lt: 0.35 } } },
  $sort: { embedding: { $vector: queryVector, $distance: 'cosine' } },
  $limit: 10,
});
```

Except for UQL, every sample here bottoms out in pgvector, which is why every one stops at PostgreSQL. UQL emits each engine’s own function instead: `VEC_DISTANCE_*` on MariaDB, `vec_distance_*` on SQLite, `vector_distance_*` on Turso and libSQL, `VECTOR_DISTANCE` on MSSQL, `$vectorSearch` on MongoDB.

| Ability | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** |
| - | - | - | - | - | - |
| Native vector operator | ✅¹ | 🔌² | ❌ | ❌³ | ✅ |
| Multi-dialect support | 🔌⁴ | 🔌⁴ | 🔌⁴ | 🔌⁴ | ✅ |
| Index migration | ✅¹ | 🔌⁵ | 🔌⁶ | ❌ | ✅ |
| Distance predicate | ✅⁷ | 🔌⁷ | ❌ | ❌ | ✅ |
| Query-time ANN tuning | ❌⁸ | ❌⁸ | ❌⁸ | ❌⁸ | ✅ |
| 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 and diffs the column (`@Column('vector', { length: 3 })`) on Postgres, MySQL/MariaDB and MSSQL, but has no distance operator on any of them: the `ORDER BY` is a hand-written string. ⁴ Every helper above is written for pgvector. On MariaDB’s `VEC_DISTANCE_*`, SQLite’s `sqlite-vec`, Turso’s `vector_distance_*`, SQL Server’s `VECTOR_DISTANCE`, or MongoDB’s `$vectorSearch` you hand-write that dialect’s own SQL or pipeline. ⁵ Reachable through a raw index expression (``@Index((doc) => [raw`...`])``), 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. ⁷ Their distance helpers are plain SQL expressions, so they compose into a `where` for free, on pgvector only. UQL’s `$near` is a typed operator that works on every engine above and refuses the metrics an engine lacks. ⁸ `hnsw.ef_search` and `ivfflat.probes` decide recall at query time, and none of them expose it: you issue the `SET LOCAL` yourself inside a transaction, and remember that it needs one.

---

## Full-text search

```ts title="Drizzle"
// No full-text API: a `sql` fragment in `where` and in `orderBy`. The GIN index is declared in the
// schema with `index().using('gin', sql`...`)` for `drizzle-kit` to emit. PostgreSQL only.
import { sql } from 'drizzle-orm';

const document = sql`to_tsvector('spanish', ${listings.title} || ' ' || ${listings.description})`;
const query = sql`websearch_to_tsquery('spanish', ${term})`;

const results = await db
  .select()
  .from(listings)
  .where(sql`${document} @@ ${query}`)
  .orderBy(sql`ts_rank(${document}, ${query}) desc`);
```

```ts title="MikroORM"
// `$fulltext` filters. Postgres weights (A to D) live in a tsvector column your entity fills on
// every write; nothing ranks, so the order is yours to write.
@Entity()
class Listing {
  @Property({
    type: new FullTextType('spanish'),
    onUpdate: (l) => ({ A: l.title, B: l.description }),
  })
  search!: WeightedFullTextValue;
}

const results = await em.find(Listing, { search: { $fulltext: term } });
```

```prisma title="Prisma"
generator client {
  previewFeatures = ["fullTextSearchPostgres"]
}
```

```ts title="Prisma"
// `search` filters and `_relevance` orders, repeating the fields and the term. No weights.
const results = await prisma.listing.findMany({
  where: { title: { search: term } },
  orderBy: {
    _relevance: {
      fields: ['title', 'description'],
      search: term,
      sort: 'desc',
    },
  },
});
```

```ts title="TypeORM"
// `@Index({ fulltext: true })` on MySQL only; the query is a hand-written string.
const results = await dataSource
  .getRepository(Listing)
  .createQueryBuilder('listing')
  .where('MATCH(listing.title, listing.description) AGAINST (:term)', { term })
  .getMany();
```

```ts title="UQL"
// One `@Index({ type: 'fulltext', config: 'spanish' })` with a weight on the title, declared once.
// The query names only the text; the fields, the language and the weights come from the index.
const results = await pool.findMany(Listing, {
  $where: { $text: { $value: 'wireless keyboard' } },
  $sort: { $text: { $project: 'score' } },
});
```

UQL runs the same query on PostgreSQL, CockroachDB, MySQL, MariaDB and MongoDB, and on SQLite once an FTS5 table exists. The others stop at the engine their sample is written for, and the ranking and weights are yours to write.

| Ability | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** |
| - | - | - | - | - | - |
| Search operator | ❌¹ | ✅² | ✅³ | ❌⁴ | ✅ |
| Relevance sort | ❌¹ | ❌ | ✅⁵ | ❌⁴ | ✅ |
| Returns the score | ❌ | ❌ | ❌ | ❌ | ✅ |
| Column weights | ❌ | 🔌⁶ | ❌ | ❌ | ✅ |
| Index in migrations | ✅¹ | ✅² | ✅⁷ | ✅⁴ | ✅ |
| One query on every engine | ❌ | 🔌² | 🔌³ | ❌ | ✅ |

¹ Plain `sql` fragments, PostgreSQL only; the GIN expression index is declared in the schema and emitted by `drizzle-kit`. ² PostgreSQL, MySQL/MariaDB and MongoDB, through `@Index({ type: 'fulltext' })` and `$fulltext`; SQLite needs an FTS5 table created by hand. On PostgreSQL the index is over `to_tsvector('simple', ...)`. ³ Behind a preview feature, on PostgreSQL and MySQL; the query syntax is the engine’s own. ⁴ `@Index({ fulltext: true })` builds a MySQL `FULLTEXT` index, and nothing else is native: the query and the ordering are a string you write. ⁵ `_relevance` on PostgreSQL and MySQL, repeating the fields and the term the filter already names. ⁶ PostgreSQL only, as `A` to `D` labels in a `tsvector` column your entity computes on every write. ⁷ `@@fulltext` on MySQL and MongoDB; a PostgreSQL index is hand-written SQL in a migration.

---

## Find: Select & filter

```ts title="Drizzle"
// Closest to the SQL, at the cost of a helper imported per operator.
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);
```

```ts title="MikroORM"
// Same shape, with `fields` in place of `select`; the rows come back as managed entities.
const results = await em.find(
  User,
  { name: 'Jane' },
  {
    fields: ['id', 'name', 'email'],
    orderBy: { createdAt: 'DESC' },
    limit: 10,
  },
);
```

```ts title="Prisma"
// Also plain JSON, but typed by the generated client rather than by your own classes.
const results = await prisma.user.findMany({
  select: { id: true, name: true, email: true },
  where: { name: 'Jane' },
  orderBy: { createdAt: 'desc' },
  take: 10,
});
```

```ts title="TypeORM"
// Reads the same on equality; anything past it is a helper function (`Like`, `MoreThan`).
const results = await manager.find(User, {
  select: { id: true, name: true, email: true },
  where: { name: 'Jane' },
  order: { createdAt: 'DESC' },
  take: 10,
});
```

```ts title="UQL"
// Plain JSON, so this whole object can be built, stored, or sent over the wire.
const results = await pool.findMany(User, {
  $select: { id: true, name: true, email: true },
  $where: { name: 'Jane' },
  $sort: { createdAt: 'desc' },
  $limit: 10,
});
```

The declarative four read the same on every vendor, but once the filter needs an operator, TypeORM’s stops being data, so only MikroORM’s, Prisma’s and UQL’s survive `JSON.stringify`.

---

## Query: Relations

```ts title="Drizzle"
// The nested `where` is an operator callback rather than an object, and works only on
// to-many. Object filters and a `where` on a to-one arrive with Relational Queries v2,
// still in the 1.0 beta line.
const results = await db.query.users.findMany({
  columns: { id: true, name: true },
  with: {
    posts: {
      columns: { title: true },
      where: (post, { eq }) => eq(post.published, true),
    },
  },
});
```

```ts title="MikroORM"
// The relation's condition is its own option, `populateFilter`, which nests as a
// LEFT JOIN, so filtering the posts does not drop the users that have none.
const results = await em.find(
  User,
  {},
  {
    fields: ['id', 'name', 'posts.title'],
    populate: ['posts'],
    populateFilter: { posts: { published: true } },
  },
);
```

```ts title="Prisma"
// Inline on a to-many, but there is no `where` on a to-one.
const results = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    posts: { select: { title: true }, where: { published: true } },
  },
});
```

```ts title="TypeORM"
// find() options cannot filter a relation independently of its parent: a nested condition
// filters the parents too, and gets unreliable past the first level. 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();
```

```ts title="UQL"
// The condition sits on the join itself, at any depth and on either side of it.
// `$required: true` promotes that join to an INNER JOIN.
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 converged. The reach has not.

---

## Aggregation & grouping

```ts title="Drizzle"
// Composed from SQL helpers, one import per operator.
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);
```

```ts title="MikroORM"
// QueryBuilder only. v7 tracks the raw aliases in the type, so `having` is checked
// against them, but the values come back as `unknown` and still need narrowing.
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');
```

```ts title="Prisma"
// Declarative and type-safe, but keyed by operator (`_avg`, `_count`) and SQL-only.
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,
});
```

```ts title="TypeORM"
// QueryBuilder only, `having` and `groupBy` are strings, and the rows arrive
// untyped: getRawMany() returns any[].
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();
```

```ts title="UQL"
// Plain JSON, identical on every SQL engine and MongoDB, and typed end to end: the
// `$group` columns, the `$select` aggregates, the `$having` and `$sort` aliases, and the rows.
const results = await pool.aggregate(User, {
  $where: { createdAt: { $gte: new Date('2025-01-01') } },
  $group: { status: true },
  $select: { count: { $count: '*' }, avgAge: { $avg: { age: true } } },
  $having: { avgAge: { $gt: 30 } },
  $sort: { count: -1 },
  $limit: 10,
});
```

Only Prisma and UQL express aggregation declaratively; the rest reach for a query builder.

One thing the samples do not show: in UQL, `$where` in `aggregate()` runs through the same filter engine as `findMany`, so [soft-delete, default, and `security` filters](https://uql-orm.dev/querying/filters.md) apply here too. Tenant scoping does not quietly stop at `GROUP BY`.

---

## Counting

```ts title="Drizzle"
// `$count` is a query of its own. A count per parent is a correlated subquery you write as
// an extra, and ordering by it repeats that subquery in the ORDER BY.
import { sql, eq } from 'drizzle-orm';

const total = await db.$count(users, eq(users.status, 'active'));

const results = await db.query.users.findMany({
  extras: {
    postCount:
      sql<number>`(SELECT count(*) FROM posts WHERE posts.author_id = ${users.id})`.as(
        'post_count',
      ),
  },
});
```

```ts title="MikroORM"
// `count` and `findAndCount` are there; there is no existence check short of counting. A
// per-parent tally is `loadCount` on each collection, so it is a query per parent.
const total = await em.count(User, { status: 'active' });
const [page, count] = await em.findAndCount(User, {}, { limit: 10 });
const postCount = await user.posts.loadCount();
```

```ts title="Prisma"
// The closest of the four: `_count` selects relation tallies, takes a filter of its own, and
// `orderBy` accepts one. No cheap existence check and no estimate: both are a full count or raw SQL.
const results = await prisma.user.findMany({
  select: { id: true, _count: { select: { posts: true } } },
  orderBy: { posts: { _count: 'desc' } },
  take: 10,
});
```

```ts title="TypeORM"
// `count`, `findAndCount`, `exists` and `existsBy` are all there. A per-parent tally is not:
// `loadRelationCountAndMap` went with 1.0, so it is a COUNT with its own GROUP BY, returned
// as raw rows you merge back onto the entities yourself.
const total = await manager.count(User, { where: { status: 'active' } });
const any = await manager.exists(User, { where: { name: 'Jane' } });

const counts = await manager
  .createQueryBuilder(User, 'user')
  .select('user.id', 'id')
  .addSelect('COUNT(post.id)', 'postCount')
  .leftJoin('user.posts', 'post')
  .groupBy('user.id')
  .getRawMany();
```

```ts title="UQL"
// Tallies are part of the read: per relation, under `_count`, and sortable by the same name
// without loading the rows they rank by.
const results = await pool.findMany(User, {
  $count: { posts: true }, // or a filter of its own: { posts: { $where: { ... } } }
  $sort: { posts: { $count: -1 } }, // the users with the most posts
  $limit: 10,
});

await pool.exists(User, { $where: { name: 'Jane' } }); // stops at the first match
await pool.count(User, { $limit: 1000 }); // capped: "1,000+ matches", no full scan
await pool.estimatedCount(User); // the engine's own statistic, no scan
```

Every one of them counts rows. What differs is the cost of the three questions around it: how many rows each parent has, whether there is at least one, and roughly how big the table is.

Prisma and UQL are the two where a relation tally is part of the read itself: named, filtered, and sorted by without writing the subquery. UQL reads it in the row’s own statement. `exists` stops at the first match rather than counting to the end, [`count`](https://uql-orm.dev/querying/counting.md) takes a `$limit` so “1,000+ matches” never scans a large table, and `estimatedCount` reads the engine’s own statistic (approximate, whole-table, and as stale as the last `ANALYZE`). `findManyAndCount` is a single statement on SQL, so a page and its total cannot disagree.

---

## Computed fields

```ts title="Drizzle"
import { sql, type SQL } from 'drizzle-orm';

// Two separate cases. Per query, as a selected expression you restate every time:
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 generated column. Filterable and sortable, but it is a
// real column, so it needs a migration (and `stored` on PostgreSQL):
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}`,
  ),
});
```

```ts title="MikroORM"
// A formula property: the expression goes into the SQL, not into JS, so it selects,
// filters, and sorts like a real column with no migration behind it.
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);
```

```ts title="Prisma"
// The only one with no database-side option, so you map after the fetch. A
// `$extends({ result })` extension centralizes this and is type-safe, but it still
// computes in JS, so you cannot filter or sort by the value:
const users = await prisma.user.findMany();
const results = users.map((u) => ({
  ...u,
  fullName: `${u.firstName} ${u.lastName}`,
}));
```

```ts title="TypeORM"
import { Entity, Column, VirtualColumn, AfterLoad } from 'typeorm';

@Entity()
class User {
  @Column() firstName: string;
  @Column() lastName: string;

  // A subquery inlined into the SELECT, and into WHERE, but not into ORDER BY.
  @VirtualColumn({
    query: (alias) =>
      `SELECT CONCAT(${alias}.firstName, ' ', ${alias}.lastName)`,
  })
  fullName: string;
}
```

```ts title="UQL"
// One expression on the entity, inlined into SELECT, WHERE and ORDER BY, or a real
// generated column with `stored: true`. It is SQL you write, so portability is yours:
// `user.firstName` becomes the alias-qualified column of whichever statement reads it.
import { raw } from 'uql-orm';

@Entity()
class User {
  @Field({ type: String }) firstName: string | null;
  @Field({ type: String }) lastName: string | null;

  @Field({
    type: String,
    computed: (user) => raw`CONCAT(${user.firstName}, ' ', ${user.lastName})`,
  })
  fullName?: string | null;
}
```

MikroORM, TypeORM, and UQL put the expression in the entity, where it filters like a real column with no schema change. TypeORM stops one step short: it inlines the expression in `WHERE` but not in `ORDER BY`, so sorting by a `@VirtualColumn` is a hand-written `addOrderBy`. That is the difference that matters: a value you can only compute after the fetch is a value you cannot query by.

---

## Mutations

```ts title="Drizzle"
// You think about what the database returned.
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));
```

```ts title="MikroORM"
// You think about the flush cycle: nothing is written until it runs.
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 });
```

```ts title="Prisma"
// You think about the row: each call writes when you make it and returns the record.
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 } });
```

```ts title="TypeORM"
// You think about the entity: `save` picks insert or update from whether the id is
// set, while `update` skips the entity and goes straight to SQL.
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);
```

```ts title="UQL"
// You think about the statement: the cardinality is in the method name, and an insert
// hands back the id rather than the row, so reading it back is a separate call.
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 is the same everywhere. The mental model is not: Drizzle hands you whatever the database returned, MikroORM defers the write to a flush cycle, and Prisma, TypeORM, and UQL just write.

**Batch inserts** are where they diverge most. UQL’s [`insertMany`](https://uql-orm.dev/querying/methods.md#insert-ids):

- returns an id per row on every database;
- accepts records with different column sets in one statement, a missing cell taking the column’s default;
- automatically chunks a batch that would exceed the driver’s bind-parameter limit.

Those ids are exact wherever the database reports them per row: PostgreSQL, CockroachDB, MariaDB, and SQLite (including LibSQL/Turso, Cloudflare D1, and Bun SQL) via `RETURNING`, MSSQL via `OUTPUT`, and MongoDB via `insertedIds`. MySQL has no `RETURNING`, so ids there are inferred, and only when that inference is sound; otherwise the entry comes back `undefined` rather than wrong.

Prisma’s `createMany` returns only a row count (`createManyAndReturn` returns ids, but not on MySQL). Drizzle, MikroORM, and TypeORM 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.

---

## Soft delete

```ts title="Drizzle"
import { eq, isNull } from 'drizzle-orm';

// No built-in soft delete: the column is yours to declare, and yours to filter on
// in every query that should not see the deleted rows.
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  deletedAt: timestamp('deleted_at'),
});

await db.update(users).set({ deletedAt: new Date() }).where(eq(users.id, 1));
const results = await db.select().from(users).where(isNull(users.deletedAt));
```

```ts title="MikroORM"
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.
```

```ts title="Prisma"
// No built-in soft delete either, and the field cannot be declared here: it is
// `deletedAt DateTime?` in schema.prisma, then yours to filter on in every query.
await prisma.user.update({ where: { id: 1 }, data: { deletedAt: new Date() } });
const results = await prisma.user.findMany({ where: { deletedAt: null } });
```

```ts title="TypeORM"
@Entity()
export class User {
  @PrimaryGeneratedColumn() id: number;
  @DeleteDateColumn() deletedAt: Date;
}
// Native support for soft-deletion and automatic filtering
await manager.softDelete(User, id);
```

```ts title="UQL"
@Entity()
export class User {
  @Id({ type: Number }) id: number;
  @Field({ type: Date, softDelete: true }) deletedAt: Date | null;
}
// Marking the field enables global soft-deletion behavior
await pool.deleteOneById(User, id); // soft delete
await pool.restoreOneById(User, id); // bring it back
await pool.deleteOneById(User, id, { hardDelete: true }); // remove for good
```

Filtering `deletedAt: null` by hand means one forgotten clause leaks deleted rows. Three of the five filter the read side for you. In UQL this is one use of its general [query filters](https://uql-orm.dev/querying/filters.md), not a special case for deletes.

---

## Filtering: Comparison operators

```ts title="Drizzle"
// A function per operator, so the filter cannot be serialized or sent over the wire.
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']),
    ),
  );
```

```ts title="MikroORM"
// An object, but every operator is offered on every field, so `{ age: { $like } }`
// compiles too. `$ilike` is PostgreSQL-only.
const results = await em.find(User, {
  age: { $gte: 18, $lte: 65 },
  name: { $ilike: 'A%' },
  status: { $nin: ['banned', 'inactive'] },
});
```

```ts title="Prisma"
// An object too, and the generated client types operators per field, so
// `{ age: { startsWith } }` fails to compile. Case-insensitivity is a `mode` flag
// instead of an operator, and only PostgreSQL and MongoDB accept it.
const results = await prisma.user.findMany({
  where: {
    age: { gte: 18, lte: 65 },
    name: { startsWith: 'A', mode: 'insensitive' },
    status: { notIn: ['banned', 'inactive'] },
  },
});
```

```ts title="TypeORM"
// Helper functions inside the object, so this one is not serializable either.
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'])),
});
```

```ts title="UQL"
// Operators are typed per field: `$like` on strings, `$gt` on comparables, `$size` on
// arrays, so `{ age: { $like: '3%' } }` fails to compile. `$istartsWith`/`$iincludes`
// emit the right SQL on every dialect rather than PostgreSQL's `ILIKE`.
const results = await pool.findMany(User, {
  $where: {
    age: { $gte: 18, $lte: 65 },
    name: { $istartsWith: 'A' },
    status: { $nin: ['banned', 'inactive'] },
  },
});
```

Only the three object filters survive `JSON.stringify`. Of those, MikroORM’s takes any operator on any field, while Prisma’s and UQL’s reject a nonsense one at compile time.

---

## 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 on every SQL dialect | ❌ | ✅² | ❌ | ❌ | ✅ |
| Declared index on a JSON path | 🔌⁵ | 🔌⁵ | 🔌⁵ | 🔌⁵ | ✅ |

¹ 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. ⁵ Reachable as a raw expression index, which is a string of that dialect’s own SQL and is not checked against the document’s shape. UQL declares the path (`@Index((user) => [{ column: user.kind, jsonPath: { path: 'theme.color', type: String } }])`), checks it against the field’s type, and emits what the engine has: a path index on PostgreSQL, CockroachDB, SQLite and MySQL, MySQL’s multi-valued index for array containment and element matches, and a refusal on MariaDB rather than DDL it would reject.

See [JSON / JSONB](https://uql-orm.dev/querying/json.md) for generated SQL examples and practical baseline dialect versions.

---

## Network boundaries & APIs

```ts title="Drizzle"
// The API layer is yours: one hand-written route per model, kept in sync by hand.
import { eq } from 'drizzle-orm';

app.get('/api/users', async (req, res) => {
  const results = await db
    .select()
    .from(users)
    .where(eq(users.status, req.query.status));
  res.json(results);
});
```

```ts title="MikroORM"
// The filter is serializable, but it stops at the server: the route is still yours.
app.get('/api/users', async (req, res) => {
  const results = await em.find(User, { status: req.query.status });
  res.json(results);
});
```

```ts title="Prisma"
// Same: the query is JSON, but nothing ships it across the wire for you.
app.get('/api/users', async (req, res) => {
  const where = { status: req.query.status };
  res.json(await prisma.user.findMany({ where }));
});
```

```ts title="TypeORM"
// Another hand-written bridge.
app.get('/api/users', async (req, res) => {
  const results = await manager.find(User, { where: { id: req.query.id } });
  res.json(results);
});
```

```ts title="UQL"
// Backend: auto-generated REST API for your entities
import { createFetchHandler } from 'uql-orm/http';

const handler = createFetchHandler({ pool, 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' },
});
```

A serializable query is only half of it; something still has to carry it. UQL’s [HTTP transport](https://uql-orm.dev/http.md) is framework-agnostic, so the same handler mounts on Hono, Elysia, Next.js, Bun, Deno, Workers, or [Express](https://uql-orm.dev/express.md), and it pairs with a typed [browser client](https://uql-orm.dev/browser.md) that carries transactions and authorization hooks.

---

## Migrations & synchronization

```bash title="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   # dialect comes from drizzle.config.ts
npx drizzle-kit push
```

```ts title="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();
```

```bash title="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
```

```bash title="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 -d ./data-source.ts ./migrations/AddNickname
```

```bash title="UQL"
# 1. You edit your entity class
# 2. UQL diffs your entity classes 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

# Or skip the file and apply the same diff in place (dev databases)
npx uql-migrate sync
```

MikroORM and UQL 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 that can drift from both sides (Drizzle).

The dev shortcut differs in what it is allowed to do. TypeORM’s `synchronize: true` and Prisma’s `db push` will drop a column or rewrite a type to reach the target schema, and MikroORM’s `schema:update` does the same unless you pass `--safe`. [`uql-migrate sync`](https://uql-orm.dev/migrations.md#syncing-without-a-migration-file) inverts that default: it creates what is missing and refuses the destructive half of the diff unless you pass `--unsafe`.

---

## Streaming

```ts title="Drizzle"
// `.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);
}
```

```ts title="MikroORM"
// AsyncIterable on every driver. On MongoDB, `populate` throws rather than being ignored.
const stream = await em.stream(User, { status: 'active' });
for await (const user of stream) {
  await writeToCsv(user);
}
```

```ts title="Prisma"
// No streaming API at all, so you drive cursor pagination yourself and carry the cursor:
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;
}
```

```ts title="TypeORM"
// 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();
for await (const row of results) {
  await writeToCsv(row);
}
```

```ts title="UQL"
// AsyncIterable on every driver, hydrated: a plain for-await loop, no event handlers.
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. Only two of the five give you one on every driver.

---

## Feature matrix

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

| Capability | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** |
| - | - | - | - | - | - |
| Serializable queries (JSON) | ❌¹⁶ | ✅¹⁷ | ✅¹⁷ | ❌¹¹ | ✅ |
| Native semantic search | ✅¹ | 🔌⁶ | 🔌⁶ | 🔌⁶ | ✅ |
| Native full-text search, ranked (see [Full-text search](#full-text-search)) | ❌ | ✅ | ✅ | ❌ | ✅ |
| Computed fields | ✅⁷ | ✅ | 🔌⁸ | ✅ | ✅ |
| No custom DSL needed | ✅ | ✅ | ❌ | ✅ | ✅ |
| No codegen needed | ✅ | ✅ | ❌ | ✅ | ✅ |
| Deep relation operators | ❌ | ✅ | ✅ | ❌ | ✅ |
| Cursor streaming (AsyncIterable) | ✅⁹ | ✅ | ❌ | 🔌¹⁰ | ✅ |
| Soft delete (built-in) + restore | ❌ | 🔌 | 🔌 | ✅⁴ | ✅ |
| Global query filters (scopes) | ❌ | ✅ | 🔌 | ❌ | ✅ |
| Multi-tenancy / RLS | ❌⁵ | ✅⁵ | 🔌⁵ | ❌ | ✅⁵ |
| Row-level locking (`FOR UPDATE`, `SKIP LOCKED`) | ✅ | ✅ | ❌¹⁴ | ✅ | ✅ |
| Optimistic locking (version column) | ❌¹⁵ | ✅ | ❌¹⁵ | ✅¹⁵ | ✅ |
| Multiple schemas | ✅ | ✅ | ✅ | ✅ | ✅ |
| Lifecycle hooks | ❌ | ✅ | 🔌 | ✅ | ✅ |
| Works without an active ORM context (no Unit of Work / flush cycle) | ✅ | ❌ | ✅ | ✅ | ✅ |
| Auto REST API | ❌ | ❌ | ❌ | ❌ | ✅ |
| Browser querier | ❌ | ❌ | ❌ | ❌ | ✅ |
| CommonJS (`require`) | ✅ | ❌¹² | ✅ | ✅ | ❌¹² |
| Data browser / GUI | ✅¹³ | ❌ | ✅¹³ | ❌ | ❌ |
| 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). All three are app-layer, so raw SQL escapes them. Database-native row-level security is the complementary backstop, enforced regardless of app code: Drizzle declares Postgres RLS policies in the schema (`pgPolicy` / `crudPolicy`), and Prisma documents driving them via an extension. Strongest isolation combines both. ⁶ See the [Semantic search](#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. MikroORM’s and Prisma’s are pgvector, so PostgreSQL-only; TypeORM’s column maps on more engines, but with no distance operator on any of them. ⁷ 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. ¹¹ Partly. TypeORM’s `find()` options are a plain object, so an equality filter does serialize; anything past that (`Between`, `ILike`, `Not`, `In`) is a function call, and the QueryBuilder is not an object at all. ¹² UQL is ESM-only, so there is no `require()` path and it needs Node 24 or newer. MikroORM v7 is ESM-only too (`@mikro-orm/core` ships no `require` condition). If you are on CommonJS, that rules both of us out. ¹³ Drizzle Studio and Prisma Studio. UQL has no GUI: you browse your data with your database’s own tooling. ¹⁴ Prisma has no row-lock option, so a work queue there is `$queryRaw`. The other four take one on a read: Drizzle’s `.for('update', { skipLocked: true })`, MikroORM’s `lockMode: LockMode.PESSIMISTIC_PARTIAL_WRITE`, TypeORM’s `setLock('pessimistic_write').setOnLocked('skip_locked')`, and UQL’s [`$lock`](https://uql-orm.dev/querying/locking.md) (`{ $wait: 'skip' | 'nowait' }`). All of them need an open transaction, and none of them is on SQLite. ¹⁵ Drizzle and Prisma have no version column: the check is a `where` you write and a row count you read. TypeORM’s `@VersionColumn` increments on save, and its `OptimisticLockVersionMismatchError` comes from a read with `lock: { mode: 'optimistic', version }`. MikroORM’s `@Property({ version: true })` is checked when the unit of work flushes. UQL matches the version in the `UPDATE` itself, on every engine. ¹⁶ Drizzle’s HTTP proxy driver does ship something, but it is the compiled `{ sql, params, method }`, not the query. The client already holds a builder and a database-shaped statement, and the server can only run it, not validate, scope or re-compile it. ¹⁷ As a plain object, yes, but only until a value stops being JSON: a `Date` becomes a string, a `BigInt` throws in `JSON.stringify`, and Prisma’s `Decimal` and MikroORM’s `RegExp` filters come back as strings or `{}`. The server has to revive them by hand.

---

## Database support

| Database | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** |
| - | - | - | - | - | - |
| Cloudflare D1 | ✅ | 🔌¹ | ✅ | ❌ | ✅ |
| CockroachDB | ✅ | ✅ | ✅ | ✅ | ✅ |
| LibSQL / Turso | ✅ | ✅ | ✅ | ❌ | ✅ |
| MariaDB | ✅ | ✅ | ✅ | ✅ | ✅ |
| MongoDB | ❌ | ✅ | ✅ | ✅ | ✅ |
| MSSQL | ✅² | ✅ | ✅ | ✅ | ✅ |
| MySQL | ✅ | ✅ | ✅ | ✅ | ✅ |
| Neon Serverless | ✅ | 🔌³ | ✅ | 🔌³ | ✅ |
| Oracle | ❌ | ✅ | ❌ | ✅ | ❌ |
| PGlite | ✅ | ✅ | 🔌⁴ | 🔌⁴ | ✅ |
| PostgreSQL | ✅ | ✅ | ✅ | ✅ | ✅ |
| SQLite | ✅ | ✅ | ✅ | ✅ | ✅ |

¹ MikroORM Cloudflare D1 support is currently documented as an experimental path through its SQL query builder. ² 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 (MikroORM’s `driverOptions`, TypeORM’s `driver` option). Drizzle, Prisma and UQL each ship a Neon entry point. ⁴ Only through a third-party package (`pglite-prisma-adapter`, `typeorm-pglite`). Drizzle, MikroORM and UQL each publish the entry point themselves.

---

## Install footprint

What each one puts on disk before you add a driver, measured on a fresh install of the package alone, September 2026.

| | **Drizzle** | **MikroORM** | **Prisma** | **TypeORM** | **UQL** |
| - | - | - | - | - | - |
| Installed | 9.9 MB | 4.8 MB | 71.2 MB | 22.5 MB | **1.5 MB** |
| Files | 2,667 | 1,154 | 94 | 3,663 | **425** |

Packages measured: `drizzle-orm` 0.45.2, `@mikro-orm/postgresql` 7.2.0, `@prisma/client` 7.10.0, `typeorm` 1.1.1, `uql-orm` 0.48.0. Every row is the ORM on its own: add `pg` to any of them and they all grow by the same amount.

92% 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.4 MB in the `fast` build, against 27.1 kB gzipped for the whole of `uql-orm/postgres`.

UQL installs one package with no runtime dependencies and 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 and worth stating: ESM-only, so there is no `require()` path, and Node 24 or newer. CI budgets the per-entry sizes and fails the build when they regress.

[Zero Dependencies: what we deleted to fit on the edge](https://uql-orm.dev/blog/zero-dependencies.md)The full table, how it was measured, and what came out to get there.

---

## Benchmark

Our [open benchmark](https://uql-orm.dev/benchmark.md) times a full PostgreSQL lifecycle per entry and measures what each ORM adds over hand-written driver code. **UQL takes first place** on both drivers, and on Bun SQL adds the least of any entry; the next ORM adds 1.5x as much and the slowest 6x. The same harness weighs the heap around each step: UQL allocates [73KB per lifecycle](https://uql-orm.dev/blog/measuring-orm-memory.md) above hand-written `pg`, the next ORM 524KB and the heaviest 3.6MB. It also compiles eleven ordinary mistakes against each tool’s own API, and UQL is the only one that catches all eleven.

[Full Benchmark Results](https://uql-orm.dev/benchmark.md)Complete result tables, methodology, interactive charts, and reproduction instructions.

[Rename Safety](https://uql-orm.dev/rename-safety.md)Six ORMs renaming a field, a foreign key and a relation: which indexes, checks, relations, queries and raw SQL follow.

---
