Switching to UQL
Your schema already exists, and so does the ORM in front of it. Neither has to move on day one. UQL keeps no identity map and no session: it takes plain objects, returns plain objects, and holds its own pool, so it runs in the same process as Prisma, Drizzle, TypeORM, MikroORM, or Mongoose while you migrate one endpoint at a time.

The work splits into four parts, in this order: scaffold entities from the live database, run UQL beside what you have, translate the queries you write today, and move traffic in phases. The last section lists the habits that translate badly.
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 known blind spots - a junction table with no foreign key constraints comes out as a plain entity, and index type, partial predicates, and INCLUDE columns are not read back - so read the generated files. See migrations for the full behavior and naming strategy for custom mappings.
Step 2: Run it beside your current ORM
Section titled “Step 2: Run it beside your current ORM”Nothing is shared between the two, which is what makes coexistence safe: 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, so lower each pool’s max so the pair stays inside the database’s connection limit.
The connection model itself is the one you already use. In Prisma, TypeORM, and Sequelize a plain query takes its own pooled connection, so Promise.all parallelizes; UQL’s pool behaves the same way for reads and writes alike, with pool.findMany(User, {...}) and pool.insertOne(User, {...}) each acquiring, running, and releasing. 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→npx prisma generate→ use the generated client. - UQL: edit the
@Entityclass → 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 TypeORM or MikroORM: explicit mutations instead of managed state
Section titled “From TypeORM or MikroORM: 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; what changes is the target, from a document to 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 to carry over. Grouped columns move out of $select into $group, and the 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 in an aggregate 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. 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 modern ESM-only. Move toimportand set"type": "module"inpackage.json.