SQL to MongoDB Converter
Write a SQL query and get MongoDB code that runs. The output is Node driver syntax, which is what a Monghoul query tab executes, so it can be pasted in and run unchanged. It reads a query back into SQL too, in either syntax. Nothing leaves your browser.
SQL and MongoDB, and the places they part
Most of SQL has a direct MongoDB form. The rest is worth knowing about before you run the query.
What converts cleanly
SELECT with WHERE, ORDER BY, LIMIT and OFFSET becomes a cursor chain. GROUP BY, HAVING and a join become an aggregation pipeline. INSERT, UPDATE, DELETE and CREATE INDEX convert as well.
Where the two disagree
SQL leaves a NULL row out of a negative test and MongoDB does not. A date written as text stays text. Each difference gets a note under the output, with the fix named, rather than a silent guess.
Driver code, not shell code
Monghoul runs the MongoDB Node driver, where find takes a filter and an options bag. The shell puts a projection in that second argument, and the driver rejects it. This writes .project() on the cursor instead, so the code runs unchanged.
SQL to MongoDB, clause by clause
The whole table the converter works from. For the pipeline stages behind the grouping and the joins, the aggregation cheat sheet carries a stage level view. To read a query the other way, use the MongoDB to SQL converter.
| SQL | MongoDB |
|---|---|
| SELECT a, b | .project({ a: 1, b: 1, _id: 0 }) |
| SELECT a AS x | .project({ x: '$a', _id: 0 }) |
| WHERE a = 1 | { a: 1 } |
| WHERE a > 1 | { a: { $gt: 1 } } |
| a IN (1, 2) | { a: { $in: [1, 2] } } |
| a BETWEEN 1 AND 9 | { a: { $gte: 1, $lte: 9 } } |
| a LIKE 'ab%' | { a: /^ab/ } |
| a NOT LIKE 'ab%' | { a: { $not: /^ab/ } } |
| a IS NULL | { a: null } |
| AND | one filter object, or $and when a key repeats |
| OR | { $or: [ … ] } |
| ORDER BY a DESC | .sort({ a: -1 }) |
| LIMIT 10 OFFSET 20 | .skip(20).limit(10) |
| SELECT DISTINCT a | .distinct('a', filter) |
| SELECT COUNT(*) | .countDocuments(filter) |
| GROUP BY a | { $group: { _id: '$a' } } |
| SUM(a), AVG(a), MIN(a), MAX(a) | { $sum: '$a' }, $avg, $min, $max |
| HAVING | a second $match, after the $group |
| JOIN t ON x = y | $lookup, then $unwind |
| LEFT JOIN | $unwind with preserveNullAndEmptyArrays |
| INSERT INTO … VALUES | .insertOne(…) or .insertMany([…]) |
| UPDATE … SET a = 1 | .updateMany(filter, { $set: { a: 1 } }) |
| UPDATE … SET n = n + 1 | .updateMany(filter, { $inc: { n: 1 } }) |
| DELETE FROM … WHERE | .deleteMany(filter) |
| CREATE UNIQUE INDEX | .createIndex(keys, { unique: true }) |
SQL to MongoDB, 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.
WHERE, ORDER BY and LIMIT become a cursor chain
SELECT name, email FROM users WHERE age > 21 AND country = 'DE' ORDER BY name LIMIT 20
db.collection('users') .find({ age: { $gt: 21 }, country: 'DE' }) .project({ name: 1, email: 1, _id: 0 }) .sort({ name: 1 }) .limit(20)
GROUP BY and HAVING become an aggregation pipeline
SELECT status, COUNT(*) AS orders, SUM(amount) AS revenue FROM orders WHERE paid = TRUE GROUP BY status HAVING COUNT(*) > 5 ORDER BY revenue DESC LIMIT 10
db.collection('orders').aggregate([ { $match: { paid: true } }, { $group: { _id: '$status', orders: { $sum: 1 }, revenue: { $sum: '$amount' } } }, { $match: { orders: { $gt: 5 } } }, { $sort: { revenue: -1 } }, { $limit: 10 }, { $project: { status: '$_id', orders: 1, revenue: 1, _id: 0 } } ])
JOIN becomes $lookup and $unwind
SELECT u.name AS customer, o.total FROM users u JOIN orders o ON u._id = o.user_id WHERE o.total > 100
db.collection('users').aggregate([ { $lookup: { from: 'orders', localField: '_id', foreignField: 'user_id', as: 'o' } }, { $unwind: '$o' }, { $match: { 'o.total': { $gt: 100 } } }, { $project: { customer: '$name', total: '$o.total', _id: 0 } } ])
- $unwind drops a document with no match, which is what INNER JOIN does. Set preserveNullAndEmptyArrays to keep it.
LIKE, IN, BETWEEN and IS NULL
SELECT * FROM logs WHERE level IN ('warn', 'error') AND message LIKE 'timeout%' AND created_at BETWEEN '2024-01-01' AND '2024-06-30' AND user_id IS NOT NULL
db.collection('logs').find({ level: { $in: ['warn', 'error'] }, message: /^timeout/, created_at: { $gte: '2024-01-01', $lte: '2024-06-30' }, user_id: { $ne: null } })
- LIKE converts to a case sensitive regular expression. MySQL's default collation matches either case, so add the i flag when that is what the query relied on.
- A date written as text stays text. MongoDB compares a string to a Date by BSON type, so a date field returns nothing. Wrap the value in ISODate('2024-01-01') when the field holds a real date.
- MongoDB has no NULL. { a: null } matches a document where the field is null and one where it is absent, which is the closest form to IS NULL. Add $exists when you need to tell them apart.
A write statement becomes updateMany
UPDATE users SET status = 'archived', logins = logins + 1 WHERE last_seen < '2023-01-01'
db.collection('users').updateMany( { last_seen: { $lt: '2023-01-01' } }, { $set: { status: 'archived' }, $inc: { logins: 1 } } )
- A date written as text stays text. MongoDB compares a string to a Date by BSON type, so a date field returns nothing. Wrap the value in ISODate('2024-01-01') when the field holds a real date.
- UPDATE changes every matching row, so this is updateMany. Change it to updateOne when you mean only the first match.
CREATE INDEX becomes createIndex
CREATE UNIQUE INDEX idx_email ON users (email, created_at DESC)
db.collection('users').createIndex( { email: 1, created_at: -1 }, { name: 'idx_email', unique: true } )
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.
Why does the output say db.collection("users") instead of db.users?
The shorthand fails for a collection whose name the driver already uses, such as stats, admin or watch. In those cases db.stats is the driver method and not your collection. db.collection(name) always reaches the collection, so the Monghoul output uses it every time.
Why not shell syntax?
The shell writes a projection as the second argument to find. The Node driver reads that argument as an options bag, so Monghoul refuses a plain field list there and ignores a computed one. The output writes .project() on the cursor instead, which the driver and the shell both accept, so there is one form and it runs everywhere. Shell syntax is still read on the way in, when you convert a query back to SQL.
How does a JOIN convert?
An equality join becomes a $lookup followed by an $unwind. INNER JOIN drops a document with no match, which is what $unwind does by default. LEFT JOIN sets preserveNullAndEmptyArrays so the document stays. A condition on the base table runs in a $match before the $lookup, where an index can still serve it.
Why does it warn me about NULL?
A SQL row always has every column, and a MongoDB document does not. WHERE a != 1 excludes a NULL row in SQL, while { a: { $ne: 1 } } also matches a document where the field is null or absent. On a sparse field that returns extra rows, so the tool says so and offers the $exists form.
What does it refuse to convert?
Subqueries, UNION, window functions, CROSS JOIN, RIGHT JOIN and FULL JOIN, COUNT(DISTINCT x), and a query placeholder such as ? or $1. Each one gets a message naming the construct and the line it sits on. A refusal is deliberate: a query that runs and returns the wrong rows is worse than one that will not convert.
Writing MongoDB queries every day?
Monghoul runs the query this page just wrote, with autocomplete that reads your real collection schemas, a visual pipeline builder for the stages a JOIN turns into, and an explain plan graded against the index it used. Free tier included.
Download Monghoul