Switching to UQL
UQL runs in the same process as Drizzle, MikroORM, Mongoose, Prisma or TypeORM, so you can move one endpoint at a time instead of all at once. It holds its own pool, keeps no identity map or session, and passes plain objects both ways.

Four moves, in this order: scaffold entities from the live database, run UQL beside what you have, translate the queries you write today, then move traffic in phases. The habits that translate badly are at the end.
Step 1: Scaffold entities from the database you have
Section titled “Step 1: Scaffold entities from the database you have”You do not hand-write entities for tables that already exist. Point the CLI at the live database and it writes the @Entity classes, including relations inferred from the foreign keys it finds:
npx uql-migrate generate:from-db --output ./src/entitiesThat needs a config with a pool. If your columns are snake_case and your code is camelCase, set the naming strategy here and the translation applies to both queries and generated DDL:
import { SnakeCaseNamingStrategy, type Config } from 'uql-orm';import { PgQuerierPool } from 'uql-orm/postgres';
const pool = new PgQuerierPool( { connectionString: process.env.DATABASE_URL }, { namingStrategy: new SnakeCaseNamingStrategy() },);
export default { pool, migrationsPath: './migrations' } satisfies Config;export { pool };Then check the scaffold against reality before trusting it:
npx uql-migrate drift:checkDrift check compares the entities to the running database and reports missing tables and columns, type mismatches, and unexpected columns. Keep it in CI: it is the same command that later tells you an entity and a legacy migration have diverged.
Introspection has one blind spot worth knowing. Relations come off foreign key constraints, so a junction table that declares none arrives as a plain entity. Indexes, by contrast, come back whole - access method, partial predicates and INCLUDE columns included. See scaffolding for what comes back, drift detection for what it compares.
This is the one time the database defines the entities. From here the arrow reverses: edit a class and generate:entities writes the migration that brings the database to match.
Step 2: Run it beside your current ORM
Section titled “Step 2: Run it beside your current ORM”Nothing is shared between the two: no cache to invalidate, no session to keep consistent, no entity that can be attached to the wrong context. The cost is a second connection pool: lower each one’s max so the pair still fits inside the database’s connection limit.
The connection model itself is the one you already use. In Prisma, Sequelize, and TypeORM a plain query takes its own pooled connection, so Promise.all parallelizes. UQL’s pool does the same for reads and writes alike: pool.findMany(User, {...}) and pool.insertOne(User, {...}) each acquire, run, and release. Reach for pool.withQuerier or pool.transaction when several statements must share one connection or commit atomically.
What you cannot do is span a transaction across both ORMs. A unit of work that writes through the legacy ORM and through UQL runs as two transactions on two connections, so migrate at the boundary of a whole unit of work rather than splitting one in half.
The shift in mental model
Section titled “The shift in mental model”Each source ORM has one idea you have to put down. The rest is vocabulary.
From Prisma: no schema file, no codegen
Section titled “From Prisma: no schema file, no codegen”Prisma keeps the schema in a .prisma file and a generate step turns it into a client, so your model and your code are two artifacts that can disagree, and CI grows a build step.
- Prisma: edit
.prisma, runnpx prisma generate, then use the generated client. - UQL: edit the
@Entityclass, then query it. The TypeScript class is the schema, and the standard decorators are checked against the properties they annotate.
From Drizzle: a declarative object instead of composed SQL
Section titled “From Drizzle: a declarative object instead of composed SQL”Drizzle builds SQL out of functions, so every condition is an import and a complex query accumulates eq(), and(), and sql templates. UQL queries are plain JSON, which is also why they survive a trip over the network.
- Drizzle:
db.select().from(users).where(and(eq(users.id, 1), gte(users.age, 18))) - UQL:
pool.findMany(User, { $where: { id: 1, age: { $gte: 18 } } })
From MikroORM or TypeORM: explicit mutations instead of managed state
Section titled “From MikroORM or TypeORM: explicit mutations instead of managed state”A Unit of Work and Identity Map track loaded entities and flush changes for you. That buys convenience and costs you detached-entity errors and writes you did not ask for. UQL never tracks an object, so a mutation happens only where you call one.
- Managed:
user.name = 'New Name'; await em.flush(); - UQL:
await pool.updateOneById(User, id, { name: 'New Name' });
From Mongoose: keep the query style, gain SQL
Section titled “From Mongoose: keep the query style, gain SQL”Mongoose filters are objects of operators ($gte, $in, $regex, $elemMatch, $or), and so are UQL’s. The vocabulary carries over almost intact. Only the target changes: a document becomes an @Entity class mapped to a table and its relations. Because the same query runs on MongoDB and every supported SQL engine, you can move off Mongo table by table instead of rewriting the data layer in one release.
- Mongoose:
User.find({ status: 'active' }).sort('-createdAt').limit(10) - UQL:
pool.findMany(User, { $where: { status: 'active' }, $sort: { createdAt: 'desc' }, $limit: 10 })
Translating what you already write
Section titled “Translating what you already write”Method and operator equivalents
Section titled “Method and operator equivalents”Pick the ORM you are coming from. Every method below is on the pool and on a querier, with the same name and arguments.
| Prisma | UQL |
|---|---|
findMany({ where, select, orderBy, take, skip }) |
findMany(User, { $where, $select, $sort, $limit, $skip }) |
findFirst({ where }) |
findOne(User, { $where }) |
findUnique({ where: { id } }) |
findOneById(User, id) |
count({ where }) |
count(User, { $where }) |
groupBy / aggregate |
aggregate(User, { $group, $agg, $having }) |
create({ data }) |
insertOne(User, data) - returns the id, not the row |
createMany({ data }) |
insertMany(User, data) - returns an id per row, on every database |
update({ where: { id }, data }) |
updateOneById(User, id, data) |
updateMany({ where, data }) |
updateMany(User, { $where }, data) |
upsert({ where, create, update }) |
upsertOne(User, conflictPaths, data) |
delete / deleteMany |
deleteOneById / deleteMany |
include / nested select |
$populate |
$transaction(fn) |
pool.transaction(fn) |
$queryRaw / $executeRaw |
all(sql, values) / run(sql, values) |
{ contains: 'x' } |
{ $includes: 'x' }, or $iincludes for mode: 'insensitive' |
{ startsWith: 'x' } |
{ $startsWith: 'x' } / { $istartsWith: 'x' } |
{ notIn: [...] } |
{ $nin: [...] } |
{ field: null } |
{ $isNull: true } |
AND / OR / NOT |
$and / $or / $not |
| Drizzle | UQL |
|---|---|
db.select().from(users).where(...) |
findMany(User, { $where }) |
db.select({ id: users.id }) |
$select: { id: true } |
db.query.users.findMany({ with: { posts: true } }) |
$populate: { posts: true } |
.orderBy(desc(users.createdAt)) |
$sort: { createdAt: 'desc' } |
.limit(n) / .offset(n) |
$limit: n / $skip: n |
db.$count(users, ...) |
count(User, { $where }) |
.groupBy().having() |
aggregate(User, { $group, $agg, $having }) |
db.insert(users).values(v).returning() |
insertOne(User, v) / insertMany(User, [v]) |
db.update(users).set(v).where(...) |
updateMany(User, { $where }, v) |
db.delete(users).where(...) |
deleteMany(User, { $where }) |
.onConflictDoUpdate({ target, set }) |
upsertOne(User, conflictPaths, data) |
db.transaction(fn) |
pool.transaction(fn) |
db.execute(sql`...`) |
all(sql, values) / run(sql, values) |
and(...) / or(...) / not(...) |
$and / $or / $not; $and is implicit between keys |
gte(users.age, 18) |
{ age: { $gte: 18 } } |
like / ilike |
{ $like } / { $ilike }, or $includes / $iincludes to skip the wildcards |
inArray / notInArray |
{ $in } / { $nin } |
isNull / isNotNull |
{ $isNull: true } / { $isNotNull: true } |
| TypeORM | UQL |
|---|---|
find(User, { where, select, order, take, skip }) |
findMany(User, { $where, $select, $sort, $limit, $skip }) |
findOne / findOneBy |
findOne(User, { $where }) |
findOneBy({ id }) |
findOneById(User, id) |
count / countBy |
count(User, { $where }) |
relations: ['posts'], leftJoinAndSelect |
$populate, with $required: true for an inner join |
createQueryBuilder().groupBy().having() |
aggregate(User, { $group, $agg, $having }) |
insert(User, data) |
insertOne(User, data) / insertMany(User, data) |
save(entity) |
saveOne(User, data) - inserts or updates on id presence |
update(User, id, data) |
updateOneById(User, id, data) |
delete(User, id) |
deleteOneById(User, id, { hardDelete: true }) |
softDelete / restore |
deleteOneById / restoreOneById |
manager.transaction(fn) |
pool.transaction(fn) |
manager.query(sql) |
all(sql, values) / run(sql, values) |
MoreThanOrEqual(18) |
{ $gte: 18 } |
Between(a, b) |
{ $between: [a, b] } |
Like('%x%') / ILike('%x%') |
{ $includes: 'x' } / { $iincludes: 'x' } |
In([...]) / Not(In([...])) |
{ $in: [...] } / { $nin: [...] } |
IsNull() |
{ $isNull: true } |
| MikroORM | UQL |
|---|---|
em.find(User, where, { fields, orderBy, limit, offset }) |
findMany(User, { $where, $select, $sort, $limit, $skip }) |
em.findOne(User, where) |
findOne(User, { $where }) / findOneById(User, id) |
em.count(User, where) |
count(User, { $where }) |
populate + populateFilter |
$populate with a $where per relation |
qb.groupBy().having() |
aggregate(User, { $group, $agg, $having }) |
em.create(...) + em.flush() |
insertOne(User, data) - there is no flush |
em.nativeUpdate(User, where, data) |
updateMany(User, { $where }, data) / updateOneById |
em.nativeDelete(User, where) |
deleteMany(User, { $where }) / deleteOneById |
em.upsert / em.upsertMany |
upsertOne / upsertMany |
em.transactional(fn) |
pool.transaction(fn) |
em.getConnection().execute(sql) |
all(sql, values) / run(sql, values) |
$gte, $nin, $like, $or, $elemMatch |
same names, and typed per field, so { age: { $like } } fails to compile |
$ilike (PostgreSQL only) |
$ilike, $istartsWith, $iincludes on every dialect |
filters: { softDelete: ... } |
@Field({ softDelete: true }), plus general query filters |
| Mongoose | UQL |
|---|---|
User.find(filter).sort().limit().skip() |
findMany(User, { $where, $sort, $limit, $skip }) |
User.findOne(filter) |
findOne(User, { $where }) |
User.findById(id) |
findOneById(User, id) |
User.countDocuments(filter) |
count(User, { $where }) |
.select('id name') |
$select: { id: true, name: true } |
.populate('posts') |
$populate: { posts: true } - a join, not a second round trip |
User.aggregate([...]) |
aggregate(User, { $group, $agg, $having }) |
User.create(doc) / insertMany |
insertOne(User, doc) / insertMany(User, docs) |
findByIdAndUpdate(id, doc) |
updateOneById(User, id, doc) |
updateMany(filter, { $set: doc }) |
updateMany(User, { $where }, doc) |
deleteOne / deleteMany |
deleteOneById / deleteMany |
session.withTransaction(fn) |
pool.transaction(fn) |
{ $regex: 'x' } |
{ $iincludes: 'x' } for a plain substring, $regex when you need the pattern |
{ $gte }, { $in }, { $nin }, $or, $elemMatch, $size |
same names |
| Subdocuments and arrays of objects | a JSON column for schemaless blobs, a relation for anything you filter or join |
Patterns worth seeing side by side
Section titled “Patterns worth seeing side by side”Four cases where the translation is more than renaming keys. The comparison page has the same patterns with the trade-offs spelled out.
Filtering, sorting, paging
Section titled “Filtering, sorting, paging”prisma.user.findMany({ where: { age: { gte: 18 }, status: 'active', email: { contains: '@uql-orm.dev' } }, orderBy: { createdAt: 'desc' }, take: 10});import { and, gte, eq, like, desc } from 'drizzle-orm';
db.select() .from(users) .where(and( gte(users.age, 18), eq(users.status, 'active'), like(users.email, '%@uql-orm.dev%') )) .orderBy(desc(users.createdAt)) .limit(10);import { MoreThanOrEqual, Like } from 'typeorm';
manager.find(User, { where: { age: MoreThanOrEqual(18), status: 'active', email: Like('%@uql-orm.dev%') }, order: { createdAt: 'DESC' }, take: 10});em.find(User, { age: { $gte: 18 }, status: 'active', email: { $like: '%@uql-orm.dev%' }}, { orderBy: { createdAt: 'DESC' }, limit: 10});User.find({ age: { $gte: 18 }, status: 'active', email: { $regex: '@uql-orm.dev' }}) .sort({ createdAt: -1 }) .limit(10);pool.findMany(User, { $where: { age: { $gte: 18 }, status: 'active', email: { $includes: '@uql-orm.dev' } }, $sort: { createdAt: 'desc' }, $limit: 10});Aggregation and grouping
Section titled “Aggregation and grouping”const results = await prisma.user.groupBy({ by: ['status'], _count: { status: true }, _avg: { age: true }, having: { age: { _avg: { gt: 30 } } }, orderBy: { _count: { status: 'desc' } }, take: 10,});import { count, avg, gt, desc } from 'drizzle-orm';
const results = await db .select({ status: users.status, count: count(), avgAge: avg(users.age), }) .from(users) .groupBy(users.status) .having(({ avgAge }) => gt(avgAge, 30)) .orderBy(desc(count())) .limit(10);const results = await manager .createQueryBuilder(User, 'user') .select('user.status', 'status') .addSelect('COUNT(*)', 'count') .addSelect('AVG(user.age)', 'avgAge') .groupBy('user.status') .having('AVG(user.age) > :minAge', { minAge: 30 }) .orderBy('count', 'DESC') .limit(10) .getRawMany();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')]) .groupBy('u.status') .having({ avgAge: { $gt: 30 } }) .orderBy({ count: 'desc' }) .limit(10) .execute('all');const results = await User.aggregate([ { $group: { _id: '$status', count: { $sum: 1 }, avgAge: { $avg: '$age' } } }, { $match: { avgAge: { $gt: 30 } } }, { $sort: { count: -1 } }, { $limit: 10 },]);const results = await pool.aggregate(User, { $group: { status: true }, // GROUP BY column, typed like $select $agg: { count: { $count: '*' }, avgAge: { $avg: 'age' } }, // computed columns $having: { avgAge: { $gt: 30 } }, $sort: { count: -1 }, $limit: 10,});Two things move in that translation. Grouped columns come out of $select into $group, and computed columns become named entries in $agg rather than operator keys (_avg) or select strings.
Everything downstream is then checked against those names: $having and $sort accept only grouped columns and $agg aliases, so a typo is a compile error and the result rows are typed. $where runs through the same filter engine as findMany, so soft-delete and tenant filters keep applying past a GROUP BY. Full reference: aggregate queries.
Atomic JSON updates
Section titled “Atomic JSON updates”Changing one key of a JSON column without reading and rewriting the whole object:
await db.execute( `UPDATE users SET settings = jsonb_set(settings, '{theme}', '"dark"') WHERE id = 1`);await pool.updateOneById(User, 1, { settings: { $set: { theme: 'dark' } }});Read-modify-write loses concurrent writes to other keys of the same document. $set, $unset, $push, and $pull compile to each dialect’s own JSON functions; see JSON / JSONB.
Semantic search
Section titled “Semantic search”Vector similarity is where a switch usually deletes code rather than moving it: the metadata filter and the similarity ranking live in one typed query, so there is no raw SQL branch and no separate vector store.
const results = await prisma.$queryRaw` SELECT id, title FROM "Article" WHERE category = 'docs' ORDER BY embedding <=> ${queryEmbedding}::vector LIMIT 5`;import { eq, cosineDistance } from 'drizzle-orm';
const results = await db.select() .from(articles) .where(eq(articles.category, 'docs')) .orderBy(cosineDistance(articles.embedding, queryEmbedding)) .limit(5);const results = await manager.createQueryBuilder(Article, 'article') .where('article.category = :category', { category: 'docs' }) .orderBy('article.embedding <=> :vector') .setParameter('vector', queryEmbedding) .limit(5) .getMany();import { cosineDistance } from 'pgvector/mikro-orm';
const results = await em.createQueryBuilder(Article, 'a') .where({ category: 'docs' }) .orderBy({ [cosineDistance('embedding', queryEmbedding)]: 'ASC' }) .limit(5) .getResult();const results = await Article.aggregate([ { $vectorSearch: { index: 'embedding_index', path: 'embedding', queryVector: queryEmbedding, filter: { category: 'docs' }, numCandidates: 100, limit: 5 } }]);const results = await pool.findMany(Article, { $where: { category: 'docs' }, $sort: { embedding: { $vector: queryEmbedding, $distance: 'cosine' } }, $limit: 5});That query runs unchanged on pgvector, CockroachDB, MariaDB, SQLite, and MongoDB Atlas. Prisma and TypeORM leave the type-safe API for raw SQL here, Drizzle and MikroORM use PostgreSQL-only pgvector helpers, and Mongoose needs an Atlas-specific pipeline. See AI & RAG for ingestion through retrieval.
Migrating in phases
Section titled “Migrating in phases”A one-release rewrite gives you no way back. These four phases each leave the legacy path intact until the new one has proven itself.
Phase 1: reads, on one endpoint
Section titled “Phase 1: reads, on one endpoint”Reimplement a single non-critical read with UQL and leave the old one running. Shadow it: run both, compare the result sets, log the differences. Plain objects in and out mean UQL cannot corrupt the other ORM’s cache or state while you do this, so the worst case is a bad response on one endpoint.
Phase 2: new tables and features
Section titled “Phase 2: new tables and features”Build everything new on UQL, with uql-migrate generate:entities creating its tables from the entity classes. This exercises the whole loop - entity, migration, query, deploy - on data no existing code depends on.
Phase 3: writes, one entity at a time
Section titled “Phase 3: writes, one entity at a time”Move INSERT, UPDATE, and DELETE per entity rather than per endpoint, so a given table has exactly one writer at a time. Keep the legacy write path in the codebase until that entity is verified in production. Where the legacy ORM had lifecycle callbacks, cascades, or validation hooks on the entity, port them to lifecycle hooks in the same change: they are the easiest thing to leave behind, and their absence is silent.
Phase 4: cutover
Section titled “Phase 4: cutover”When no query runs through the legacy ORM, remove it from package.json along with its .prisma file, Drizzle snapshots, or data source config. Keep the old migration history table if it records applied SQL you may still need to audit. Your @Entity classes are then the only schema definition left, and drift:check in CI is what keeps them honest.
Habits to unlearn
Section titled “Habits to unlearn”- Implicit flushes. Coming from MikroORM or TypeORM:
user.name = 'Bob'does nothing to the database. UQL never sees the assignment. CallupdateOneById. - Looking for the schema file. Coming from Prisma: the
@Entityclass is the schema. If a field or relation is not on the class, it does not exist as far as UQL is concerned. - Running a generate step. There is no codegen. Save the file and generate a migration.
- Embedded documents. Coming from Mongoose: nested data becomes either a JSON column or a related entity you reach with
$populate. JSON for schemaless blobs, a relation for anything you filter, sort, or join across. require(). UQL is ESM only, with norequire()path to fall back to. Move toimport, and add"type": "module"topackage.jsonif Node runs your compiled output. See Requirements for the threetsconfig.jsonsettings that go with it.