How it works
Understanding the analyzer, the SQL to MongoDB converter, the ObjectId converter, and how to get MongoDB explain output.
SQL to MongoDB converter
The SQL to Mongo page translates a SQL SELECT statement into the equivalent MongoDB command, entirely in your browser. A plain SELECT/WHERE query becomes a db.collection.find() — WHERE supports comparisons, AND/OR/NOT, IN, LIKE, BETWEEN, and IS NULL, plus ORDER BY, LIMIT, and OFFSET.
A query with GROUP BY — with COUNT, SUM, AVG, MIN, or MAX in the SELECT list — becomes an aggregate() pipeline instead: WHERE becomes a leading $match, GROUP BY and the aggregate functions become $group, and HAVING becomes a second $match after it. An aggregate expression must have an alias (e.g. COUNT(*) AS total) since Mongo needs a field name for the result; every plain column in the SELECT list must appear in GROUP BY, matching standard SQL rules.
HAVING accepts either style of referencing an aggregate: the SELECT list's alias directly (HAVING total > 5), or the repeated aggregate expression that most databases actually require there since a HAVING clause can't see SELECT-list aliases (HAVING COUNT(*) > 5). The second form is matched against the SELECT list by function and argument and substituted with its alias, since that's the field $group actually produces — an aggregate referenced in HAVING that isn't also in the SELECT list is rejected rather than silently computed twice.
INSERT INTO table (cols…) VALUES (…) becomes insertOne() for one row or insertMany() for several — an explicit column list is required, since without a schema there's no way to match bare values to field names. DELETE FROM table WHERE … becomes deleteMany() with the WHERE clause converted the same way a SELECT's WHERE would be; a DELETE with no WHERE still converts (to deleteMany({})) but the output calls out that it removes every document.
UNION (and UNION DISTINCT) over the same table is supported: two SELECTs against one collection are just two WHERE clauses that match either row, so they merge into a single filter with $or — no separate query round-trip needed. A trailing ORDER BY/LIMIT/OFFSET after the last SELECT applies to the merged result, matching standard SQL UNION rules. UNION ALL is rejected — it can return the same document twice, which a single find() has no way to express. UNION across different tables is also rejected — MongoDB has a $unionWith aggregation stage for that, but it's not implemented yet.
A single INNER JOIN or LEFT [OUTER] JOIN on an equality condition (e.g. ON o.customerId = c.id) becomes an aggregate() pipeline: a $lookup pulls in the joined collection under the join's table alias, then $unwind flattens it — with preserveNullAndEmptyArrays: true for a LEFT JOIN, so unmatched base-table rows survive. Because of this, every column reference in a JOIN query must be qualified with its table alias: a base-table column like o.status becomes plain status (it's top-level after the lookup), while a joined column like c.name stays as the dotted path c.name — there's no schema to resolve an unqualified name against, so it's rejected rather than guessed.
RIGHT/FULL/CROSS JOIN, more than one JOIN, and JOIN combined with GROUP BY are rejected in v1 to keep the single $lookup pipeline correct and easy to reason about. Subqueries and UPDATE statements aren't supported yet either — subqueries hit the same problem as multi-table JOINs: an assumed relationship between collections that this tool has no way to infer.
An alias on a plain column (SELECT id AS orderId or the bare form SELECT id orderId) is parsed and ignored rather than rejected — the output still uses the original column name, since a MongoDB projection can't rename a field without an aggregation pipeline. An alias on an aggregate expression (COUNT(*) AS total) is required and does name the output field, since $group accumulators have no original field name to fall back on.
ObjectId to timestamp converter
The ObjectId converter extracts the creation timestamp embedded in a MongoDB ObjectId: the first 4 of its 12 bytes are a Unix timestamp (seconds). The remaining 5 random + 3 counter bytes can't be reversed into anything meaningful. It also works the other way — paste a date or Unix timestamp to get an example ObjectId with a matching timestamp prefix (the rest of the bytes are randomly generated, since they were never meant to be reconstructed).
Getting an explain result
Run one of these in your MongoDB shell or driver. Use executionStats verbosity for full statistics:
// MongoDB shell — find query
db.collection.find({ field: value }).explain("executionStats")
// Alternative form
db.collection.explain("executionStats").find({ field: value })
// Aggregation pipeline
db.collection.explain("executionStats").aggregate([
{ $match: { field: value } },
{ $group: { _id: "$category", total: { $sum: 1 } } }
])Copy the entire JSON output (including all outer braces) and paste it into the analyzer.
What the analyzer does
- 1.Parse — Your JSON is parsed in the browser using the native JSON.parse. Input is validated against known explain landmarks before analysis.
- 2.Detect shape — The tool recognises find vs aggregation explains, classic vs SBE engine output, and planner-only (no executionStats) results.
- 3.Normalize — The raw explain tree is mapped to a uniform structure with consistent node IDs, stage names, and metrics regardless of MongoDB version.
- 4.Analyze — Deterministic rules fire against the normalized tree. Each finding includes exact evidence — field paths, measured numbers, and the node it refers to.
- 5.Render — A visual tree, metric cards, and findings list are rendered. All data stays in browser memory and is cleared when you click "New plan."
Supported explain shapes
| Shape | Support |
|---|---|
| find + executionStats (classic) | Full |
| find + queryPlanner only | Full (no runtime findings) |
| find + SBE engine | Conceptual queryPlan tree; raw engine detail optional |
| aggregation + executionStats | Pipeline stages + embedded $cursor plan |
| Sharded cluster | Limited — shape detected, raw JSON shown |
| Atlas Search explain | Not supported in this version |
Reading the findings
Each finding includes:
- Observed — what was measured (exact numbers, not interpretations)
- Why it matters — performance context
- What to check — investigation direction (never a definitive prescription)
- Evidence — the exact field values used to fire the rule
Thresholds are heuristics. A finding is a starting point for investigation, not a confirmed problem. An index suggestion is a hypothesis — always test with your actual data distribution and query patterns.