Skip to content
Monghoul
Free tool · Runs in your browser

MongoDB to SQL Converter

Paste a MongoDB query and read it as SQL. A find with its cursor chain, an aggregation pipeline, or a write. Driver syntax and shell syntax both read. Nothing leaves your browser.

Paste a MongoDB query
Driver syntax or shell syntax. A find with its cursor chain, an aggregation pipeline, or a write.
Try a sample
Output
Standard SQL. A MongoDB shape with no SQL form is named rather than guessed at.
Paste a MongoDB query above and the SQL appears here.

MongoDB and SQL, and the places they part

Most of a query has a direct SQL form. The rest is worth knowing about before you trust the reading.

The mapping

What reads back cleanly

A find with its cursor chain becomes a SELECT. A pipeline of $match, $group, $sort and $lookup becomes WHERE, GROUP BY, ORDER BY and JOIN. The writes read back as INSERT, UPDATE and DELETE.

The refusals

What has no SQL form

$elemMatch, $exists, $expr and the array operators work on shapes a SQL column does not have. A pipeline whose stages run in an order SQL cannot express, such as a $match after a $limit, is refused rather than quietly reordered.

Both syntaxes

Driver code or shell code

Paste either. The one place they collide is the second argument to find, a projection in the shell and an options bag in the driver. This reads it the way the driver does, and says so when both readings are possible.

MongoDB to SQL, operator by operator

The whole table the converter works from. For what each pipeline stage does on its own, the aggregation cheat sheet covers every one. To go the other way, use the SQL to MongoDB converter.

MongoDB SQL
.find({}) SELECT * FROM …
.project({ a: 1, _id: 0 }) SELECT a
.project({ x: '$a' }) SELECT a AS x
{ a: 1 } WHERE a = 1
{ a: { $gt: 1 } } WHERE a > 1
{ a: { $in: [1, 2] } } a IN (1, 2)
{ a: { $gte: 1, $lte: 9 } } a BETWEEN 1 AND 9
{ a: /^ab/ } a LIKE 'ab%'
{ a: null } a IS NULL
{ a: { $ne: null } } a IS NOT NULL
{ $or: [ … ] } OR
.sort({ a: -1 }) ORDER BY a DESC
.skip(20).limit(10) LIMIT 10 OFFSET 20
.countDocuments(filter) SELECT COUNT(*)
.distinct('a') SELECT DISTINCT a
{ $group: { _id: '$a' } } GROUP BY a
{ $sum: '$a' }, $avg, $min, $max SUM(a), AVG(a), MIN(a), MAX(a)
{ $sum: 1 } COUNT(*)
a second $match after $group HAVING
$lookup then $unwind INNER JOIN
$unwind with preserveNullAndEmptyArrays LEFT JOIN
{ $count: 'n' } SELECT COUNT(*) AS n
.insertOne(…) / .insertMany([…]) INSERT INTO … VALUES
.updateMany(filter, { $set: … }) UPDATE … SET
.updateMany(filter, { $inc: { n: 1 } }) UPDATE … SET n = n + 1
.deleteMany(filter) DELETE FROM … WHERE
.createIndex(keys, { unique: true }) CREATE UNIQUE INDEX

MongoDB to SQL, worked through

Every example below is produced by the converter on this page when the build runs, so what you read here is what the tool writes.

A cursor chain becomes a SELECT

MongoDB
db.collection('users')
  .find({ age: { $gt: 21 }, country: 'DE' })
  .project({ name: 1, email: 1, _id: 0 })
  .sort({ name: 1 })
  .limit(20)
SQL
SELECT name, email
FROM users
WHERE age > 21 AND country = 'DE'
ORDER BY name ASC
LIMIT 20

$group and $match become GROUP BY and HAVING

MongoDB
db.collection('orders').aggregate([
  { $match: { paid: true } },
  { $group: { _id: '$status', revenue: { $sum: '$amount' } } },
  { $sort: { revenue: -1 } },
  { $limit: 10 }
])
SQL
SELECT status, SUM(amount) AS revenue
FROM orders
WHERE paid = TRUE
GROUP BY status
ORDER BY revenue DESC
LIMIT 10

$lookup becomes a JOIN, and the alias becomes the table name

MongoDB
db.collection('users').aggregate([
  {
    $lookup: {
      from: 'orders',
      localField: '_id',
      foreignField: 'user_id',
      as: 'o'
    }
  },
  { $unwind: '$o' },
  { $match: { 'o.total': { $gt: 100 } } }
])
SQL
SELECT *
FROM users
INNER JOIN orders ON users._id = orders.user_id
WHERE orders.total > 100

The two argument projection, which Monghoul refuses to run

MongoDB
db.users.find({ status: 'paid' }, { total: 1, _id: 0 })
SQL
SELECT total
FROM users
WHERE status = 'paid'
  • The second argument reads as a shell projection. Monghoul runs the Node driver, where that argument is an options bag, so this exact code throws there. Write .project({ … }) instead.

updateMany becomes UPDATE, and $inc becomes an addition

MongoDB
db.collection('users').updateMany(
  { last_seen: { $lt: '2023-01-01' } },
  { $set: { status: 'archived' }, $inc: { logins: 1 } }
)
SQL
UPDATE users
SET status = 'archived', logins = logins + 1
WHERE last_seen < '2023-01-01'

Frequently asked questions

Does my query leave the browser?

No. The parser and the code generator are JavaScript that runs on this page. There is no server call, no account, and no upload.

Can I paste shell syntax?

Yes. db.users.find({}, { name: 1 }) and db.collection("users").find({}).project({ name: 1 }) both read. The second argument to find is the one ambiguous spot, because it means a projection in the shell and an options bag in the driver. It is read the driver way, and a note says so when both readings hold.

How does a $lookup become a JOIN?

A $lookup followed by an $unwind becomes an INNER JOIN, because $unwind drops a document with no match. When the $unwind sets preserveNullAndEmptyArrays it becomes a LEFT JOIN. A $lookup with no $unwind is refused: it embeds an array on each document, and a JOIN makes one row per match, so the two return different shapes.

Why does it refuse some pipelines?

SQL runs its clauses in a fixed order and a pipeline does not. A $match after a $limit filters inside the limited set, and no SQL statement means that. Sorting the stages into clauses anyway would produce SQL that looks right and returns different rows, so it refuses and says which stage is the problem.

What happens to an ObjectId?

SQL has no ObjectId type, so the 24 character hex string is written as text and a note points it out. ISODate and new Date become their literal, and NumberLong, NumberInt and NumberDecimal become the number.

Will the SQL run against my database?

Treat it as a reading of the query rather than a statement to execute. MongoDB documents have no fixed columns, so a nested field becomes a dotted column name and a missing field has no exact SQL counterpart. The value here is understanding what a pipeline does, and every place the two languages disagree carries a note.

Reading someone else's pipeline?

Monghoul runs the query and shows you the plan: every stage named, the scan efficiency worked out, and the index that would remove a collection scan. There is a visual pipeline builder for the stages a JOIN turns into. Free tier included.

Download Monghoul