From Mongoose to SQL


The first time I wrote MongoDB queries with Mongoose, ~2015, I felt the real power of JSON in a database. SQL databases were only starting to support it (Postgres had just shipped jsonb), and no ORM handled JSON well (even now there aren’t many); so I had to write it, with one unified query language for all (SQL and MongoDB), and then moving between them would be quick. UQL is that ORM, and that is why its queries look kind of MongoDB’s.
Another part in the history of “why UQL” is because GraphQL felt like a monster to me (in most cases), so I had to write something better, simpler and cleaner, but that is another story my friend…
Below is how you can move from Mongoose/MongoDB to PostgreSQL simply, without a big rewrite. Let me introduce why teams commonly make this move.
Why do teams leave MongoDB?
Section titled “Why do teams leave MongoDB?”It’s mostly because their data turned out to be relational after all. In their own words:
- Infisical needed replica sets just to get transactions, lost
CASCADEand “left dangling resources”, and ran “many $lookup operations to simulate joins”. After the move, their database bill dropped by half. - At Jsmon, “schema constraints caught bugs that had been silently lurking for months”.
- Voucherify kept part of their data in each database, a “doubled codebase, paradigms and concepts” for every feature, until they moved it all.
What makes the move hard?
Section titled “What makes the move hard?”1. Ids and references
Section titled “1. Ids and references”Every ObjectId, and every reference pointing at one, has to survive the move. Infisical kept a LevelDB map from the old ids to the new ones.
2. Every query is rewritten
Section titled “2. Every query is rewritten”Infisical rewrote “dozens of data structures and hundreds of queries” in three to four months. Voucherify spent three months on the code, then three more moving the data.
3. Embedded documents lose their update operators
Section titled “3. Embedded documents lose their update operators”Arrays and subdocuments become JSON columns, where SQL has no $push, $pull or $addToSet. Each becomes hand-written JSON SQL that differs per database. Some argue that on Postgres “the JSON operations are not atomic”, but a single UPDATE is atomic for its row, so the real question is how much you put in one statement.
4. Both databases must agree before you switch
Section titled “4. Both databases must agree before you switch”The safest cutover is the one The Guardian used, a proxy that sent traffic to both APIs and compared their answers before switching. It is also the most expensive, with two data layers running side by side.
Why UQL makes it simple?
Section titled “Why UQL makes it simple?”Because UQL runs the same entities and queries on MongoDB and on almost every well-known SQL database. That splits the move in two. First you port the queries from Mongoose to UQL one at a time, shipping each on the MongoDB you already run. Then you switch the database, and the queries stay as they are. While both databases run, the second one is another pool in the same codebase.
1. The entities read the collections as Mongoose left them
Section titled “1. The entities read the collections as Mongoose left them”These Mongoose models:
const User = model( 'User', new Schema({ name: { type: String, required: true }, settings: { theme: String, notify: { email: Boolean } }, }),);
const Post = model( 'Post', new Schema({ title: { type: String, required: true }, likes: Number, tags: [String], author: { type: Schema.Types.ObjectId, ref: 'User', required: true }, }),);^ that become these entities below, which read the collections as stored, author reference included, and create matching tables on SQL, plus a pool on the MongoDB you run today:
import { ObjectId } from 'mongodb';import { Entity, Field, Id, ManyToOne, OneToMany, type Json } from 'uql-orm';import { MongodbQuerierPool } from 'uql-orm/mongodb';
const objectId = () => new ObjectId().toHexString();
@Entity({ name: 'users' })export class User { @Id({ type: String, onInsert: objectId }) id?: string; @Field({ type: String, nullable: false }) name!: string; @Field({ type: 'jsonb' }) settings?: Json<{ theme?: string; notify?: { email?: boolean }; }> | null; @OneToMany({ entity: () => Post, mappedBy: (post) => post.author }) posts?: Post[];}
@Entity({ name: 'posts' })export class Post { @Id({ type: String, onInsert: objectId }) id?: string; @Field({ type: String, nullable: false }) title!: string; @Field({ type: Number }) likes?: number | null; @Field({ type: 'jsonb' }) tags?: Json<string[]> | null; @Field({ name: 'author', references: () => User, nullable: false }) authorId!: string; @ManyToOne({ entity: () => User, references: (post) => post.authorId }) author?: User;}
export const pool = new MongodbQuerierPool(process.env.MONGO_URL!);In code, an id is a 24-character hex string. On MongoDB, UQL stores it as an ObjectId, and stores the author reference the same way. That lets Mongoose and UQL share the collections while you port: Mongoose’s populate reads a post UQL wrote, and UQL reads what Mongoose updates.
On SQL, the same string is the key. References and URLs keep working without the id map Infisical needed, and objectId gives rows inserted on SQL later an id of the same form.
2. Port each query once
Section titled “2. Port each query once”Most of a filter carries over as is, and the chained calls become keys of one object:
await Post.find({ title: { $regex: '^launch', $options: 'i' }, likes: { $gte: 3 },}) .select('title tags author') .populate('author', 'name') .sort({ likes: -1 }) .limit(10);await pool.findMany(Post, { $select: { title: true, tags: true }, $populate: { author: { $select: { name: true } } }, $where: { title: { $regex: '(?i)^launch' }, likes: { $gte: 3 } }, $sort: { likes: -1 }, $limit: 10,});On MongoDB the $populate runs as a $lookup, on Postgres as a join, and both return the same rows.
Where Mongoose needed an aggregation pipeline, the port is shorter. The top authors by post count:
await User.aggregate([ { $lookup: { from: 'posts', localField: '_id', foreignField: 'author', as: 'posts', }, }, { $project: { name: 1, posts: { $size: '$posts' } } }, { $sort: { posts: -1 } }, { $limit: 10 },]);await pool.findMany(User, { $select: { name: true }, $count: { posts: true }, $sort: { posts: { $count: -1 } }, $limit: 10,});The query names no collection and no join keys, because the entities already declare the relation. It runs on Postgres unchanged.
3. JSON updates stay atomic
Section titled “3. JSON updates stay atomic”In UQL the update operators change one part of a JSON column in one statement, on SQL as on MongoDB:
await pool.updateOneById(Post, postId, { likes: { $inc: 1 }, tags: { $pull: ['draft'], $addToSet: ['release'] },});
await pool.updateOneById(User, userId, { settings: { $set: { 'notify.email': false } },});MongoDB itself refuses $pull and $addToSet on one field (“Updating the path ‘tags’ would create a conflict at ‘tags’”). UQL applies them in order, on every database.
4. Mirror, copy, compare
Section titled “4. Mirror, copy, compare”Stripe’s recipe for moving live data starts with “dual writing to the existing and new tables to keep them in sync”, then moves the reads, then the writes. With a second pool on Postgres, each step is a few lines.
Every write goes to MongoDB, then to SQL. MongoDB is still the source of truth, and a failure on SQL is only logged:
import type { QuerierPool } from 'uql-orm';import { PgQuerierPool } from 'uql-orm/postgres';
const pg = new PgQuerierPool({ connectionString: process.env.DATABASE_URL });
async function mirror<T>(write: (db: QuerierPool) => Promise<T>): Promise<T> { const result = await write(pool); await write(pg).catch((error) => console.warn('mirror failed', error)); return result;}
await mirror((db) => db.insertOne(Post, { id: objectId(), title: 'Hello', authorId: userId }),);await mirror((db) => db.updateOneById(Post, postId, { likes: { $inc: 1 } }));Inserts name their id up front, and both databases store that id. Port a collection’s writes before mirroring it, since a mirror only sees writes that go through UQL.
Next, a copy streams each collection into its table, parents first. saveMany upserts each row by its id, which makes the copy safe to run again at any time. Each run repairs whatever a failed mirror missed.
async function copy<E extends object>(entity: new () => E, batchSize = 1000) { let batch: E[] = []; for await (const row of pool.findManyStream(entity, {})) { batch.push(row); if (batch.length === batchSize) { await pg.saveMany(entity, batch); batch = []; } } if (batch.length) { await pg.saveMany(entity, batch); }}
await copy(User);await copy(Post);Last comes The Guardian’s comparison, minus the proxy. Each read runs on both pools and logs any difference.
import { isDeepStrictEqual } from 'node:util';
// MongoDB omits a missing field and SQL returns null: drop both.const plain = (rows: unknown) => JSON.parse(JSON.stringify(rows, (_key, value) => value ?? undefined));
async function shadow<T>(read: (db: QuerierPool) => Promise<T>): Promise<T> { const [served, shadowed] = await Promise.all([read(pool), read(pg)]); if (!isDeepStrictEqual(plain(served), plain(shadowed))) { console.warn('shadow read differs', { served, shadowed }); } return served;}
const posts = await shadow((db) => db.findMany(Post, { $where: { likes: { $gte: 3 } }, $sort: { likes: -1 } }),);Once the answers agree under real traffic, serve reads from SQL. Then swap the two pools in mirror. SQL takes each write first, and MongoDB stays current in case you need to roll back. Retire the collections once nothing reads them.
What doesn’t carry over?
Section titled “What doesn’t carry over?”A few things behave differently on SQL:
nullcompares as each database compares it:{ status: { $ne: 'archived' } }leaves out a row with no status on SQL, and matches it on MongoDB. Shadow reads catch these.- SQL foreign keys reject the dangling references MongoDB accepted. Expect to clean some up, as Infisical and Jsmon did.
$wherewith JavaScript, positional$updates and arbitrary pipeline stages have no portable equivalent. Relations, as above, andaggregatecover what most pipelines are for.
Every snippet above ran against MongoDB 8 and Postgres 18 before this was published. The full Mongoose-to-UQL table is in the migration guide, and the mirror, copy and comparison steps are on the MongoDB page. If it helps, please star UQL ⭐ on GitHub.
UQL is a JSON-native TypeScript ORM for Node.js and Bun. Supports PostgreSQL, PGlite, MySQL, MariaDB, MSSQL, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.