SQL to MongoDB Converter
Translate SQL SELECT statements into MongoDB find() or aggregation pipelines for mongosh — entirely on this device.
The find() or aggregation pipeline appears here as you type.
Clause-by-clause mapping
Each SQL clause and the MongoDB operator it became appears here, one row per clause, linked to the manual.
The declared SELECT subset
SELECT columns, *, DISTINCT, COUNT(*), COUNT(DISTINCT col), SUM, AVG, MIN and MAX · FROM one collection · WHERE with =, !=, <, <=, >, >=, IN, LIKE, BETWEEN, IS NULL, AND/OR/NOT · GROUP BY with HAVING · ORDER BY · LIMIT and OFFSET. Everything else is refused by name — JOINs, subqueries, UNION and non-SELECT statements included.
Press ? for shortcuts
Do it offline:$ mongosh --eval 'db.orders.find({ status: "paid" }).sort({ total: -1 }).limit(10)'
How to use it
- Paste a SELECT statement — WHERE, GROUP BY, HAVING, ORDER BY, LIMIT and OFFSET are all understood, and translation re-runs on every keystroke.
- Read the mongosh output on the right: the badge under the header says whether you got a find() query or an aggregation pipeline.
- Check the clause table below the panes — each SQL clause sits beside the operator it became, with a link into the MongoDB manual for that operator.
- Copy the query, download it as a .js file for a mongosh session, or share a link that carries the SQL and the pipeline toggle.
Type a SQL SELECT on the left and read the equivalent mongosh query on the right — a find() call when one suffices, an aggregation pipeline when GROUP BY, HAVING or DISTINCT demands one. A clause-by-clause table links every translated part to the MongoDB manual, and anything outside the declared subset is refused by name instead of guessed at. Translation happens on your device; the SQL is never uploaded.
How it works
The translator reuses this site's SQL span scanner, so quoted regions are settled before any grammar work begins: a WHERE value containing the word SELECT can never derail the parse, because string literals are opaque tokens by the time clauses are recognized. Each clause then maps to its documented MongoDB counterpart — WHERE into a filter or $match, GROUP BY into $group with the key under _id, HAVING into a second $match that runs after grouping, ORDER BY into sort, LIMIT and OFFSET into limit and skip. The clause table prints exactly this mapping for the query you typed, one row per clause.
LIKE becomes a $regex under a strict escape law. Wildcards translate (% to .*, _ to .), the pattern is anchored with ^ and $, and the fourteen regex metacharacters are backslash-escaped so a dot in your pattern matches a literal dot, never any character. One optimization is applied: a pattern shaped like %text% with no inner wildcard emits an unanchored /text/, the contains form the MongoDB mapping chart itself uses. Whenever an escape fired, a note names the characters it protected.
The find-versus-aggregate decision is mechanical: projection, filtering, sorting and paging all fit a single find() call, so plain SELECTs emit one. Grouping, aggregate functions and DISTINCT have no find() spelling — those queries emit a pipeline in the fixed stage order $match, $group, $match for HAVING, $sort, $skip, $limit. A toggle forces the pipeline form for plain SELECTs too, useful when the query will grow $lookup or $facet stages by hand later; the forced form projects last so sorting still sees every field.
Examples
SELECT name, seats FROM rooms WHERE building IN ('A', 'B') AND seats >= 8 ORDER BY seats DESCdb.rooms.find({ building: { $in: ["A", "B"] }, seats: { $gte: 8 } }, { name: 1, seats: 1, _id: 0 }).sort({ seats: -1 })IN becomes $in and the two ANDed predicates merge into one filter document, the shape the mapping chart prefers over an explicit $and. The projection lists both columns and switches _id off, since SQL never asked for it.
SELECT * FROM users WHERE email LIKE '%@example.com'db.users.find({ email: /^.*@example\.com$/ })The dot in .com is escaped, so this matches the literal domain rather than any character — the run reports Regex metacharacters in LIKE patterns were escaped to match literally: . as a chip under the output.
SELECT customer_id, SUM(price) AS total FROM orders GROUP BY customer_id HAVING SUM(price) > 250 ORDER BY total DESCdb.orders.aggregate([
{ $group: { _id: "$customer_id", total: { $sum: "$price" } } },
{ $match: { total: { $gt: 250 } } },
{ $sort: { total: -1 } }
])The group key rides in _id — the documented $group shape — and the alias total names the accumulator, so HAVING and ORDER BY both resolve to it: HAVING lands as a $match placed after the $group stage, never before it.
SELECT title, published_at FROM posts WHERE status = 'published' ORDER BY published_at DESC, title ASC LIMIT 10 OFFSET 20db.posts.find({ status: "published" }, { title: 1, published_at: 1, _id: 0 }).sort({ published_at: -1, title: 1 }).skip(20).limit(10)Multi-key ordering carries over key by key (DESC is -1), and the paging pair maps to cursor methods in the logical order sort, then skip, then limit — page three of a feed, ten posts per page.
SELECT COUNT(DISTINCT customer_id) FROM orders WHERE total > 100db.orders.aggregate([
{ $match: { total: { $gt: 100 } } },
{ $group: { _id: "$customer_id" } },
{ $group: { _id: null, count: { $sum: 1 } } }
])Counting distinct values takes two $group stages — collapse to one document per customer, then count the documents. The chip DISTINCT rides a $group stage here — the alternative db.collection.distinct() returns bare values instead of documents explains the choice.
Every example on this page is executed against the same engine that powers the tool, on every build.
Gotchas worth knowing
JOINs are refused, not approximated
MongoDB spells a left outer join as the $lookup aggregation stage, but a mechanical SQL-to-$lookup rewrite gets unwinding, null rows and inner-versus-outer semantics subtly wrong — so this page refuses JOIN by name and leaves $lookup to you, with the manual as the map. Embedding related data in one document is the more idiomatic escape from most joins anyway.
null and missing are one thing to a filter
{ field: null } matches documents where the field is null AND documents where the field is absent — SQL tables have no second case, documents do. IS NULL therefore casts a slightly wider net than its SQL reading, and IS NOT NULL symmetrically requires the field to exist with a non-null value.
Negation drags null rows back in
SQL's three-valued logic silently drops NULL rows from every negated predicate: status != 'paid' excludes rows whose status is NULL. The translated { status: { $ne: "paid" } } includes documents with a null or missing status. The translation is the documented standard; the divergence is pinned by this page's own test corpus rather than papered over.
$regex is case-sensitive; your LIKE may not have been
MySQL's default collation makes LIKE 'a%' match Apple; PostgreSQL's LIKE does not. The emitted regex takes the PostgreSQL reading — case-sensitive, no i flag — so MySQL users porting case-blind searches should add the i flag by hand and accept the index implications.
OFFSET without ORDER BY skips arbitrary documents
Neither SQL nor MongoDB promises a row order until you sort, so skip(20) over an unsorted cursor discards whichever twenty documents the server happened to surface first. The run warns rather than refuses — sometimes arbitrary is fine — but paging UIs should always sort first.
Huge integer literals are spliced verbatim
A WHERE id = 9007199254740993 keeps those exact digits in the emitted code — the translator never rounds a literal through a float. mongosh itself evaluates bare numbers as doubles, though, so for 64-bit ids wrap the literal in Long() before running it.
Common errors
Only SELECT statements are translated — this one starts with UPDATE (INSERT, UPDATE, DELETE and DDL are out of scope).- Write statements have no query-shaped counterpart this page could honestly emit — updateOne and deleteMany carry their own operator families. Paste the SELECT part of your workload instead.
JOIN is not translated — a half-right join pipeline is worse than a refusal. The JOIN edge note on this page covers the manual route.- A typed refusal with the offending keyword's line and column. Strip the JOIN and translate the single-collection core, then add a hand-written $lookup stage where the pipeline needs it.
Subqueries are not translated — the inner SELECT has no direct MongoDB equivalent here.- IN (SELECT …), scalar subqueries and CTEs all stop the run at the inner SELECT's position. Run the inner query first and inline its result as a literal list, which the IN operator translates happily.
Use IS NULL / IS NOT NULL — a comparison with NULL is never true in SQL's three-valued logic.- = NULL is a classic SQL slip this tool refuses to launder into { field: null } silently, because the two behave differently. Rewrite with IS NULL and the translation proceeds — the null edge note explains what the filter then matches.
FAQ
Is my SQL sent to a server to be translated?
No — the parser and the emitter are a few kilobytes of JavaScript running in this page, and the network panel of your browser will show no request carrying your paste. Credential-shaped input is additionally kept out of between-visit storage and flagged before it rides a share link.
When do I get find() and when a pipeline?
Projection, filters, sorting and paging fit a single find() call, so plain SELECTs emit one. GROUP BY, HAVING, aggregate functions and DISTINCT force the aggregation form, and a toggle forces it for every query when you want stages to extend by hand.
Will the translated query return exactly my SQL rows?
For positive predicates, yes on the same data. Two documented bends exist: filters treat a missing field like null, and negated predicates ($ne, $nin, $not) include null-or-missing documents that SQL's three-valued logic drops. Both are spelled out in the edge notes and pinned by this page's test corpus.
Which SQL dialect does the parser expect?
A generic SELECT subset that most dialects share: single quotes for strings (doubled or backslash-escaped), backticks or double quotes for identifiers, MySQL's LIMIT offset, count shorthand and the standard LIMIT … OFFSET … both parse. The declared subset is printed beside the clause table, and anything outside it is refused by name.
Why does COUNT(email) get refused when COUNT(*) works?
COUNT(column) counts only non-null values, and translating it as a document count would quietly change your number. Rather than emit a conditional $sum you did not ask for, the tool supports COUNT(*) and COUNT(DISTINCT column) and names the gap.
How much SQL can I paste at once?
One statement of up to 65,536 characters — over that the run refuses with the cap and your actual size, and nothing is truncated. A script with several statements translates the first and says how many were left untouched, so nothing disappears silently.
Keyboard shortcuts
- ⌘/Ctrl + Enter
- Re-run the translation on demand.
- ⌘/Ctrl + S
- Download the query as a .js file.
- ⌘/Ctrl + C
- Copy the whole query when no text is selected.
- ⌘/Ctrl + Shift + C
- Copy the whole query even mid-selection.
- t
- Swap between the light and dark palettes.
- ? or ⌘/Ctrl + /
- Pull up the shortcut reference dialog.
- Esc
- Close the open dialog.