Drizzle vs MikroORM vs Prisma vs TypeORM vs UQL
Here are the most common ORM operations, written in Drizzle, MikroORM, Prisma, TypeORM and UQL. The text below each example explains shortly how it differs.
| Library | Version |
|---|---|
| Drizzle | 0.45.4 |
| MikroORM | 7.2.7 |
| Prisma | 7.10.1 |
| TypeORM | 1.1.2 |
| UQL | 0.112.1 |
Entity definition
Section titled “Entity definition”// Dialect-specific helpers, one import per column type.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),});// `defineEntity`; decorators live in 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);// Its own language, with a generate step.model User { id Int @id @default(autoincrement()) email String @unique name String? companyId Int company Company @relation(fields: [companyId], references: [id])}// Needs `experimentalDecorators` and `emitDecoratorMetadata`, unless you use `EntitySchema`.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;}// Standard TC39 decorators, no compiler flags. Or no decorators: `defineEntity` (/entities/define-entity).import { f, Entity, Field, ManyToOne } from 'uql-orm';
@Entity()export class User { @Field(f.number().autoIncrement()) id?: number; @Field(f.string().unique()) email!: string; @Field(f.string().nullable()) name?: string | null; @Field(f.references(() => Company)) companyId!: number; @ManyToOne(() => Company, { references: (user) => user.companyId }) company?: Company;}Only Prisma uses its own schema language.
Select and filter
Section titled “Select and filter”Ten users aged 18 to 65 whose name starts with “A”, newest first, with only two columns.
// Closest to SQL. A function per operator, so the filter cannot be serialized.import { and, desc, gte, ilike, lte, notInArray } from 'drizzle-orm';
const results = await db .select({ id: users.id, name: users.name }) .from(users) .where( and( gte(users.age, 18), lte(users.age, 65), ilike(users.name, 'A%'), notInArray(users.status, ['banned', 'inactive']), ), ) .orderBy(desc(users.createdAt)) .limit(10);// Operators typed per field. Case-insensitive `mode` works only on PostgreSQL, CockroachDB and MongoDB.const results = await prisma.user.findMany({ select: { id: true, name: true }, where: { age: { gte: 18, lte: 65 }, name: { startsWith: 'A', mode: 'insensitive' }, status: { notIn: ['banned', 'inactive'] }, }, orderBy: { createdAt: 'desc' }, take: 10,});// Plain on equality; any other operator is a function inside the object.import { Between, ILike, In, Not } from 'typeorm';
const results = await manager.find(User, { select: { id: true, name: true }, where: { age: Between(18, 65), name: ILike('A%'), status: Not(In(['banned', 'inactive'])), }, order: { createdAt: 'DESC' }, take: 10,});// Operators typed per field: `{ age: { $like: '3%' } }` fails to compile.// `$istartsWith` and `$iincludes` work on every dialect.const results = await pool.findMany(User, { $select: { id: true, name: true }, $where: { age: { $gte: 18, $lte: 65 }, name: { $istartsWith: 'A' }, status: { $nin: ['banned', 'inactive'] }, }, $sort: { createdAt: 'desc' }, $limit: 10,});MikroORM’s em.find takes a plain object too, but accepts any operator on any field, so { age: { $like: '3%' } } compiles, and its $ilike is PostgreSQL-only. Only MikroORM’s, Prisma’s and UQL’s queries survive JSON.stringify, so you can build them, store them or send them over the wire.
Relations
Section titled “Relations”Users with only their published posts.
// The nested `where` is a callback, to-many only. Object filters and to-one `where` are in the 1.0 beta.const results = await db.query.users.findMany({ columns: { id: true, name: true }, with: { posts: { columns: { title: true }, where: (post, { eq }) => eq(post.published, true), }, },});// Inline on the relation: a to-many or an optional to-one takes `where`.const results = await prisma.user.findMany({ select: { id: true, name: true, posts: { select: { title: true }, where: { published: true } }, },});// The condition sits on the join, at any depth, to-one or to-many. `$required: true` makes it an INNER JOIN.const results = await pool.findMany(User, { $select: { id: true, name: true }, $populate: { posts: { $select: { title: true }, $where: { published: true } }, },});TypeORM’s find() cannot filter a relation without also filtering the parents, so it takes a QueryBuilder leftJoinAndSelect with a string condition. MikroORM’s populateFilter does it as a LEFT JOIN, so users with no matching posts stay.
Aggregation
Section titled “Aggregation”Users created since 2025, counted per status with their average age, keeping groups whose average age is over 30.
// 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);// Declarative and typed, but keyed by operator (`_avg`, `_count`). 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,});// Plain JSON on every SQL engine and MongoDB, typed from `$group` through to 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 aggregate declaratively. MikroORM and TypeORM need their QueryBuilder: MikroORM checks having against the aliases but returns the values as unknown, and TypeORM takes string groupBy and having and returns any[]. In UQL, soft-delete, default and security filters apply to aggregate() too, so tenant scoping covers grouped queries.
Counting
Section titled “Counting”A total, a per-user post count to sort by, and a cheap “does any exist”.
// `_count` selects, filters and sorts relation tallies. No cheap existence check or estimate.const results = await prisma.user.findMany({ select: { id: true, _count: { select: { posts: true } } }, orderBy: { posts: { _count: 'desc' } }, take: 10,});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 matchawait pool.count(User, { $limit: 1000 }); // capped: "1,000+ matches", no full scanawait pool.estimatedCount(User); // the engine's own statistic, no scanDrizzle’s db.$count() is a query of its own, and a per-parent count is an sql subquery you write in extras and repeat to sort by. MikroORM has em.count and em.findAndCount but no existence check, and collection.loadCount() runs one query per parent. TypeORM has count and exists, but a per-parent count is a QueryBuilder GROUP BY returning raw rows you merge yourself. In Prisma and UQL a relation count is part of the read: filtered and sortable with no subquery. UQL’s estimatedCount is approximate and whole-table; see Counting. Its findManyAndCount reads a page and its total in one SQL statement in most cases, so the two agree.
Computed fields
Section titled “Computed fields”A fullName built from two columns, usable in a filter.
// A `sql` expression, restated in every query. A `generatedAlwaysAs` column is the reusable form, but needs a migration.import { sql } from 'drizzle-orm';
const results = await db .select({ id: users.id, fullName: sql<string>`CONCAT(${users.firstName}, ' ', ${users.lastName})`, }) .from(users);// A SQL formula: selects, filters and sorts like a column, no migration.import { defineEntity, p } from '@mikro-orm/core';
const UserSchema = defineEntity({ name: 'User', properties: { firstName: p.string(), lastName: p.string(), fullName: p.formula<string>( (cols) => `CONCAT(${cols.firstName}, ' ', ${cols.lastName})`, ), },});// No database-side option. `$extends({ result })` computes in JS, so you cannot filter or sort by it.const users = await prisma.user.findMany();const results = users.map((u) => ({ ...u, fullName: `${u.firstName} ${u.lastName}`,}));// Inlined into SELECT, WHERE and ORDER BY, or a real column with `.stored()`.// You write the SQL, so portability is up to you.import { Computed, f, sql } from 'uql-orm';
@Entity()class User { @Field(f.string()) firstName!: string; @Field(f.string()) lastName!: string;
@Computed((user) => f.string().as(sql`CONCAT(${user.firstName}, ' ', ${user.lastName})`), ) readonly fullName!: string;}MikroORM, TypeORM and UQL declare the expression on the entity, so you can filter by it with no schema change. TypeORM’s @VirtualColumn is inlined into SELECT and WHERE but not ORDER BY. A value computed after the fetch, as in Prisma, cannot be filtered or sorted in the database.
Mutations
Section titled “Mutations”One insert and one bulk update.
// You get what the database returned.import { eq } from 'drizzle-orm';
const [user] = await db.insert(users).values({ name: 'Jane' }).returning();
await db .update(users) .set({ status: 'archived' }) .where(eq(users.status, 'inactive'));// Nothing is written until `flush` runs.const user = em.create(User, { name: 'Jane' });await em.flush();
await em.nativeUpdate(User, { status: 'inactive' }, { status: 'archived' });// Cardinality is in the method name. An insert returns the id, or the row with `{ returning: true }`.const id = await pool.insertOne(User, { name: 'Jane' });
await pool.updateMany( User, { $where: { status: 'inactive' } }, { status: 'archived' },);MikroORM waits for a flush; the others write right away. Prisma’s create returns the record, and TypeORM’s save inserts or updates depending on the id.
Batch inserts differ most. UQL’s insertMany returns an id per row on every database, accepts rows with different columns in one statement, and splits a batch past the driver’s parameter limit. Prisma’s createMany returns only a count (createManyAndReturn returns rows, but not on MySQL). On MySQL, which has no RETURNING, Drizzle, MikroORM and TypeORM count up from the one reported insertId, so a batch mixing explicit and generated ids gets wrong ids; UQL sends those rows in a statement of their own.
Soft delete
Section titled “Soft delete”Drizzle and Prisma have no built-in soft delete: you add a deletedAt column and filter it in every query, so one missed clause leaks deleted rows. MikroORM hides them with a default filter (cond: { deletedAt: null }), but turning em.remove() into an update needs an onFlush subscriber.
// Built-in soft delete, restore and read filtering.@Entity()export class User { @PrimaryGeneratedColumn() id: number; @DeleteDateColumn() deletedAt: Date;}
await manager.softDelete(User, id);import { f, Entity, Field } from 'uql-orm';// Marking the field turns on soft delete for every read and delete.@Entity()export class User { @Field(f.number().autoIncrement()) id: number; @Field(f.date().nullable().softDelete()) deletedAt: Date | null;}
await pool.deleteOneById(User, id); // soft deleteawait pool.restoreOneById(User, id); // bring it backawait pool.deleteOneById(User, id, { hardDelete: true }); // remove for goodIn UQL, soft delete is one use of query filters.
Indexes
Section titled “Indexes”One email per tenant, ignoring soft-deleted rows: a composite, unique, partial index.
// Preview feature `partialIndexes`. Checked, but only literals, `null` and `{ not }`; else `raw("...")`.model User { id Int @id @default(autoincrement()) email String tenantId Int deletedAt DateTime?
@@unique([email, tenantId], where: { deletedAt: null })}// Columns and `where` are checked against the entity: `where: { deletedAt: 'yes' }` fails to compile.import { f, Entity, Field } from 'uql-orm';
@Entity({ indexes: (user, index) => [ index([user.email, user.tenantId], { unique: true, where: { deletedAt: null }, }), ],})export class User { @Field(f.number().autoIncrement()) id?: number; @Field(f.string()) email!: string; @Field(f.number()) tenantId!: number; @Field(f.date().nullable().softDelete()) deletedAt?: Date | null;}All five can declare this index; they differ in what gets checked. Drizzle types the columns but takes the predicate as an sql fragment. TypeORM takes columns and predicate as strings (where: '"deletedAt" IS NULL'). MikroORM checks the property names but not its where object.
MySQL has no partial indexes. MikroORM emulates one with a functional index, Prisma rejects the where, and UQL throws when it generates the migration instead of indexing the whole table. See Rename Safety for which declarations follow a rename, and Indexes for every index option.
| 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 update (push, pull, add) |
❌¹ | ❌² | ❌ | ❌ | ✅ |
JSON array query operators (size, all, elem) |
❌¹ | ✅² | 🔌⁴ | 🔌⁴ | ✅ |
| Same JSON API on every SQL dialect | ❌ | ✅² | ❌ | ❌ | ✅ |
| Declared index on a JSON path | 🔌⁵ | 🔌⁵ | 🔌⁵ | 🔌⁵ | ✅ |
¹ Raw SQL.
² One JSON query API across SQL dialects (nested objects, $elemMatch), but updates replace the whole object.
³ On some connectors, with connector-specific limits.
⁴ Through dialect-specific SQL or query-builder escape hatches.
⁵ As a raw, unchecked expression index. UQL’s index reads the path off the column’s ref, checks it against the field’s type, and emits a path index on PostgreSQL, CockroachDB, SQLite, MySQL and MongoDB; MariaDB throws.
See JSON / JSONB for the generated SQL and minimum dialect versions.
Semantic search
Section titled “Semantic search”The ten items closest to a query embedding.
// pgvector types, distance helpers and HNSW/IVFFlat indexes. PostgreSQL only.import { cosineDistance } from 'drizzle-orm';
const results = await db .select() .from(items) .orderBy(cosineDistance(items.embedding, queryVector)) .limit(10);// `$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,});Drizzle’s helpers are pgvector only. MikroORM and TypeORM order by a hand-written SQL string, and Prisma needs $queryRaw. UQL’s query runs on PostgreSQL, CockroachDB, MariaDB, SQLite, libSQL/Turso, MSSQL and MongoDB Atlas, through each engine’s own vector functions.
| Ability | Drizzle | MikroORM | Prisma | TypeORM | UQL |
|---|---|---|---|---|---|
| Native vector operator | ✅¹ | ❌ | ❌ | ❌² | ✅ |
| Multi-dialect support | 🔌³ | 🔌³ | 🔌³ | 🔌³ | ✅ |
| Index migration | ✅¹ | 🔌⁴ | 🔌⁵ | ❌ | ✅ |
| Distance predicate | ✅⁶ | ❌ | ❌ | ❌ | ✅ |
| Query-time ANN tuning | ❌⁷ | ❌⁷ | ❌⁷ | ❌⁷ | ✅ |
| JSON path vectors | ❌ | ❌ | ❌ | ❌ | ✅ |
¹ pgvector only. drizzle-kit generate emits an index declared with index().using('hnsw', ...).
² Maps the column (@Column('vector', { length: 3 })) on Postgres, MySQL/MariaDB and MSSQL, with no distance operator.
³ On any engine but PostgreSQL you write that engine’s SQL or pipeline by hand.
⁴ Through a raw index expression, not a declared vector index.
⁵ @@index on a vector column emits a plain B-tree; HNSW/IVFFlat need Unsupported("vector(n)") and a hand-written migration.
⁶ Drizzle’s helpers are SQL expressions, so they work in a where, on pgvector only.
⁷ UQL’s $candidates sets hnsw.ef_search or ivfflat.probes for the query. With the others you run SET LOCAL yourself inside a transaction.
Full-text search
Section titled “Full-text search”Listings matching a search term, best match first.
// No full-text API: `sql` fragments, 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`);// Preview feature `fullTextSearchPostgres`. `search` filters and `_relevance` orders, repeating fields and term. No weights.const results = await prisma.listing.findMany({ where: { title: { search: term } }, orderBy: { _relevance: { fields: ['title', 'description'], search: term, sort: 'desc', }, },});// Fields, language and weights come from one index, `{ type: 'fulltext', config: 'spanish' }`.const results = await pool.findMany(Listing, { $where: { $text: { $value: 'wireless keyboard' } }, $sort: { $text: { $project: 'score' } },});UQL runs this query on PostgreSQL, CockroachDB, MySQL, MariaDB and MongoDB, and on SQLite once an FTS5 table exists. MikroORM filters with $fulltext across engines but cannot rank. TypeORM’s query is a MATCH ... AGAINST string on MySQL.
| Ability | Drizzle | MikroORM | Prisma | TypeORM | UQL |
|---|---|---|---|---|---|
| Search operator | ❌¹ | ✅² | ✅³ | ❌⁴ | ✅ |
| Relevance sort | ❌¹ | ❌ | ✅⁵ | ❌⁴ | ✅ |
| Returns the score | ❌ | ❌ | ❌ | ❌ | ✅ |
| Column weights | ❌ | 🔌⁶ | ❌ | ❌ | ✅ |
| Index in migrations | ✅¹ | ✅² | ✅⁷ | ✅⁴ | ✅ |
| One query on every engine | ❌ | 🔌² | 🔌³ | ❌ | ✅ |
¹ sql fragments, PostgreSQL only; drizzle-kit emits the GIN index.
² PostgreSQL, MySQL/MariaDB and MongoDB via a type: 'fulltext' index and $fulltext; SQLite needs a hand-made FTS5 table. On PostgreSQL the index uses to_tsvector('simple', ...).
³ Preview feature, PostgreSQL and MySQL; the query syntax is the engine’s own.
⁴ @Index({ fulltext: true }) builds a MySQL FULLTEXT index; query and ordering are strings.
⁵ _relevance on PostgreSQL and MySQL, repeating the fields and term.
⁶ PostgreSQL only, as A to D labels in a tsvector column you compute on every write.
⁷ @@fulltext on MySQL and MongoDB; a PostgreSQL index is hand-written SQL.
Querying over HTTP
Section titled “Querying over HTTP”With Drizzle, MikroORM, Prisma and TypeORM you write and maintain an API route per model, even where the query itself is a plain object. UQL generates the routes for the entities you list, and the browser sends the same query object:
// Serverimport { createFetchHandler } from 'uql-orm/http';
const handler = createFetchHandler({ pool, include: [User] });
// Browserimport { HttpQuerier } from 'uql-orm/browser';
const http = new HttpQuerier('/api');const { data: results } = await http.findMany(User, { $where: { status: 'active' },});The HTTP transport mounts on Hono, Elysia, Next.js, Bun, Workers or Express. Its typed browser client supports transactions and authorization hooks.
Migrations
Section titled “Migrations”# Diff your entities against the live database and write a timestamped migration.npx uql-migrate generate:entities add_nicknamenpx uql-migrate up
# Or apply the same diff in place, with no file (dev databases).npx uql-migrate sync
# Fail when the database has drifted from its entities.npx uql-migrate drift:checkMikroORM works the same way: entities are the source of truth, diffed against the live database. Prisma diffs its own schema language through a shadow database (prisma migrate dev). Drizzle diffs the TypeScript schema against a stored snapshot (drizzle-kit generate). TypeORM generates by diffing a live database (typeorm migration:generate).
The in-place modes differ in safety. TypeORM’s synchronize: true and Prisma’s db push drop columns or change types to reach the target, as does MikroORM’s schema:update without --safe. uql-migrate sync only adds what is missing and refuses destructive changes unless you pass --unsafe.
Streaming
Section titled “Streaming”Streaming keeps memory flat when you export millions of rows:
// An AsyncIterable of entities, on every driver.const results = await pool.findManyStream(User, { $where: { status: 'active' },});for await (const user of results) { await writeToCsv(user);}MikroORM’s em.stream() is also an AsyncIterable on every driver, though populate throws on MongoDB. Drizzle’s .iterator() exists only on MySQL-family drivers (mysql2, PlanetScale, TiDB, SingleStore). TypeORM’s .stream() yields raw rows as a Node stream, and PostgreSQL also needs pg-query-stream. Prisma has no streaming, so you page through with a cursor yourself.
Feature matrix
Section titled “Feature matrix”The rest at a glance. ✅ built in, 🔌 through an extension or plugin, ❌ not available.
| Capability | Drizzle | MikroORM | Prisma | TypeORM | UQL |
|---|---|---|---|---|---|
| Serializable queries (JSON) | ❌¹ | ✅² | ✅² | ❌³ | ✅ |
| No custom DSL or codegen | ✅ | ✅ | ❌ | ✅ | ✅ |
| Entities without decorators | ✅ | ✅ | ❌ | ✅ | ✅ |
| Standard (TC39) decorators | ❌ | ✅ | ❌ | ❌ | ✅ |
| Deep relation operators | ❌ | ✅ | ✅ | ❌ | ✅ |
| Global query filters (scopes) | ❌ | ✅ | 🔌 | ❌ | ✅ |
| Multi-tenancy | ❌⁴ | ✅⁴ | 🔌⁴ | ❌ | ✅⁴ |
Row-level locking (FOR UPDATE, SKIP LOCKED) |
✅ | ✅ | ❌⁵ | ✅ | ✅ |
| Optimistic locking (version column) | ❌⁶ | ✅ | ❌⁶ | ✅ | ✅ |
| Lifecycle hooks | ❌ | ✅ | 🔌 | ✅ | ✅ |
| Database triggers declared on the entity | ❌ | ✅⁷ | ❌ | ❌ | ✅⁷ |
| Works without a Unit of Work / flush cycle | ✅ | ❌ | ✅ | ✅ | ✅ |
| Auto REST API and browser querier | ❌ | ❌ | ❌ | ❌ | ✅ |
CommonJS (require) |
✅ | ❌⁸ | ✅ | ✅ | ❌⁸ |
Bun built-in drivers (bun:sql, bun:sqlite) |
✅ | ❌ | ❌ | ❌ | ✅ |
| Data browser / GUI | ✅⁹ | ❌ | ✅⁹ | ❌ | ❌ |
| One query model across SQL and MongoDB | ❌ | ✅ | 🔌¹⁰ | ❌¹¹ | ✅ |
¹ Drizzle’s HTTP proxy sends compiled { sql, params, method }, so the server can run a query but not validate, scope or recompile it.
² Until a value is not JSON: a Date becomes a string, a BigInt throws, and Prisma’s Decimal and MikroORM’s RegExp arrive as strings or {}. The server must revive them by hand. UQL reads a Date and a bigint back by the entity, both ways.
³ Equality filters serialize, but Between, ILike, Not and In are functions.
⁴ App-level, so raw SQL escapes them. UQL’s security filters cannot be bypassed and fail closed without a request context; MikroORM’s can be turned off per query, and Prisma’s client extensions bypassed. Drizzle declares Postgres row-level security policies (pgPolicy) instead.
⁵ A work queue needs $queryRaw. UQL’s is $lock. Every ORM needs a transaction for it, and none locks rows on SQLite.
⁶ You write the where and check the row count yourself. UQL checks the version in the UPDATE itself, on every engine.
⁷ Installed by schema sync and migrations, SQL engines only. MikroORM’s body is SQL you write per dialect; UQL’s trigger bodies are typed against the entities and rendered per engine.
⁸ UQL and MikroORM v7 are ESM-only. UQL also needs Node 22.18 or newer.
⁹ Drizzle Studio and Prisma Studio. With UQL, use your database’s own tools.
¹⁰ Mostly consistent, but connector capabilities and raw-query APIs differ.
¹¹ The MongoDB path behaves differently from SQL (QueryBuilder support, for one).
Database support
Section titled “Database support”| Database | Drizzle | MikroORM | Prisma | TypeORM | UQL |
|---|---|---|---|---|---|
| Cloudflare D1 | ✅ | 🔌¹ | ✅ | ❌ | ✅ |
| CockroachDB | ✅ | ✅ | ✅ | ✅ | ✅ |
| LibSQL / Turso | ✅ | ✅ | ✅ | ❌ | ✅ |
| MariaDB | ✅ | ✅ | ✅ | ✅ | ✅ |
| MongoDB | ❌ | ✅ | ✅ | ✅ | ✅ |
| MSSQL | ✅² | ✅ | ✅ | ✅ | ✅ |
| MySQL | ✅ | ✅ | ✅ | ✅ | ✅ |
| Neon Serverless | ✅ | 🔌³ | ✅ | 🔌³ | ✅ |
| Oracle | ❌ | ✅ | ❌ | ✅ | ❌ |
| PGlite | ✅ | ✅ | 🔌⁴ | 🔌⁴ | ✅ |
| PostgreSQL | ✅ | ✅ | ✅ | ✅ | ✅ |
| SQLite | ✅ | ✅ | ✅ | ✅ | ✅ |
¹ Experimental, through the SQL query builder.
² In the v1.0 beta, not a stable release.
³ No adapter: pass the @neondatabase/serverless pool to the PostgreSQL driver yourself (MikroORM’s driverOptions, TypeORM’s driver).
⁴ Through a third-party package (pglite-prisma-adapter, typeorm-pglite).
Install footprint
Section titled “Install footprint”Disk size of each package alone, before adding a driver, measured on a fresh install.
| Drizzle | MikroORM | Prisma | TypeORM | UQL | |
|---|---|---|---|---|---|
| Installed | 10.0 MB | 4.9 MB | 71.2 MB | 22.6 MB | 1.9 MB |
| Files | 2,691 | 1,154 | 94 | 3,664 | 483 |
Packages: drizzle-orm 0.45.4, @mikro-orm/postgresql 7.2.5, @prisma/client 7.10.1, typeorm 1.1.2 (September 2026), uql-orm 0.106.1 (October 2026). A driver such as pg adds the same to each.
92% of @prisma/client is WebAssembly query compilers, one per engine. Per dialect, Prisma’s PostgreSQL compiler is 4.4 MB (fast build), against 45.8 kB gzipped for all of uql-orm/postgres.
UQL is one package with no runtime dependencies. Its fetch-based drivers (Turso Cloud, Neon, Cloudflare D1) need no native binaries, so they fit edge bundles.
Benchmark
Section titled “Benchmark”The benchmark measures what each ORM adds over hand-written driver code on PostgreSQL. In the latest run UQL adds the least measured time on both pg and Bun SQL, the least memory (122 KB per request, against 523 KB for Drizzle), and is the only entry that catches all twelve common mistakes at compile time.
Try it
npm install uql-ormEntities are plain classes and queries are plain JSON: nothing to generate, and no schema file to keep in sync.