Skip to content

The MongoDB editor takes a JSON command, not shell syntax

A statement beginning db. cannot run here at all, and the two operations that look alike have very different default bounds on the result set.

Paste db.orders.aggregate([{ $group: { _id: "$status", n: { $sum: 1 } } }]) into the editor and nothing runs. That is correct MongoDB, and this editor cannot run it: the MongoDB provider parses a JSON command object and nothing else. The parser wants two keys, collection and operation, and a shell line offers neither.

This is not a gap waiting to be closed by a translation layer. It is the whole shape of the provider, and the same shape is what the confirmation gate reads before a delete is allowed to run.

Why the editor takes a command object

The MongoDB provider extends BaseDatabaseProvider directly rather than the SQL base class, and it declares queryLanguage: 'json'. query() parses the string as an MQL object and dispatches on operation. Eleven operations are supported: find, findOne, aggregate, count, distinct, insertOne, insertMany, updateOne, updateMany, deleteOne, deleteMany.

{ "collection": "users", "operation": "find", "filter": { "age": { "$gt": 18 } }, "options": { "limit": 10 } }

A missing collection or operation, or a string that is not valid JSON, is a QueryError that quotes the format it wanted. The engine row on the databases page states the consequence flatly: no SQL translation layer is faked, and queries here are MongoDB queries.

The cost of that decision is not hidden either. Because there is no single-collection UPDATE ... SET for the results grid to emit, inline row editing is not offered on MongoDB at all - supportsInlineRowEdit: false, and the control is absent rather than present and broken.

The one place this rule is stated to a machine rather than a person is the agent’s plan contract, which carries the JSON envelope verbatim and names mongosh as the excluded form. That sentence exists because a plan run on 2026-08-22 drafted db.orders.aggregate([...]) - correct MongoDB, unrunnable here. Naming what the language is did not survive contact with the model’s prior; naming what it is not did. Plan mode opens on a MongoDB connection and drafts statements for a human to run. Agent AUTO mode does not run here at all: the read-only execution profile is database-native and exists only on PostgreSQL, SQLite and DuckDB, so an auto run on MongoDB ends engine-unsupported.

find, aggregate, distinct and their real defaults

The envelope is uniform. What the envelope’s options object means is not, and this is the part that surprises people who have read the driver documentation.

Operation What options does
find honours projection, sort, skip, limit; with no limit, capped at 100 documents
findOne honours projection only; sort, skip and limit are silently ignored
aggregate ignored entirely; no limit or skip reaches the cursor, and there is no default cap
distinct ignored entirely; the field comes from a required top-level field key

Two of those rows are the ones that bite. A find is capped at 100 documents when no explicit limit is given, while an aggregate has no cap at all. And findOne honours only projection, silently ignoring sort, skip and limit - they are not refused, they are dropped.

That last one is worth a sentence of its own, because it fails quietly and plausibly. This does not return the newest document:

{ "collection": "events", "operation": "findOne", "options": { "sort": { "_id": -1 } } }

It returns whichever document the server hands back first, and there is no error to tell you the sort was discarded. Use find with sort and limit: 1 when the order matters.

distinct has its own required key, and it is required on purpose. It reads its field from the top-level field, the driver’s own parameter name:

{ "collection": "products", "operation": "distinct", "field": "category", "filter": { "active": true } }

A missing or non-string field is a QueryError that names the key it wanted. It used to read the field from the first key of options.projection and fall back to _id, which meant a query asking for categories answered 120 rows of _id against a fixture of 120 products in five categories, measured on 2026-08-22. options.projection is not an alias for field. An error naming the missing key is a better answer than a plausible wrong one.

The pipeline that nothing bounds for you

supportsExternalQueryLimiting is false on this provider, and that flag is load-bearing. On the SQL engines the query route can wrap a statement to bound the result set. Here it cannot, so prepareQuery() returns the JSON unchanged and injects nothing. find still gets its 100 because the cap lives inside the find branch of query(). aggregate passes none of options to the cursor, so the only thing that bounds a pipeline is a $limit stage you wrote yourself.

{
  "collection": "orders",
  "operation": "aggregate",
  "pipeline": [
    { "$group": { "_id": "$status", "count": { "$sum": 1 } } },
    { "$sort": { "count": -1 } },
    { "$limit": 200 }
  ]
}

An unbounded $unwind over a large collection will do exactly what you asked for, all of it, into a browser grid. There is also no cancelQuery on this provider: a running operation can only be stopped through the maintenance killOp, which needs the opid and the privileges, and the maintenance toolkit is admin-only. Bounding the pipeline yourself is the mechanism, not a habit.

One related edge: the unlimited query option is ignored here. prepareQuery() always returns limit: options.limit || 100, and the route computes hasMore = rows.length === prepared.limit from that figure, so an “unlimited” request can report a hasMore that is wrong.

Which operations ask for confirmation, and how they are recognised

The execution confirmation gate is usually described in SQL terms - it is the thing that asks before a DELETE FROM runs. On MongoDB it reads the same JSON envelope the editor does, so it is one reader over one shape rather than a second parser written for this engine.

It asks before running when the parsed document’s operation is deleteOne, deleteMany, updateOne or updateMany, and when an aggregate pipeline carries a top-level $out or $merge stage - the two stages that write a collection rather than return rows.

The interesting branch is the fourth one. A payload the reader cannot read as a document with a string operation also asks for confirmation rather than staying silent. A half-typed object asks. db.users.deleteMany({}) asks - not because the gate understands mongosh, but because it refuses to certify a payload it could not parse. An unreadable statement is not a safe statement, and the gate declines to say otherwise. The editor will still reject that line; the gate’s answer just does not depend on the editor rejecting it first.

Reading values the driver hands back

Every returned document passes through a serializer on the way to the grid, and it normalises four BSON types: ObjectId and Decimal128 become strings, Date becomes ISO-8601, and Binary becomes the placeholder <Binary: N bytes> rather than raw bytes. Nested objects and arrays are walked recursively.

Only those four are special-cased. Long, Timestamp, UUID, RegExp, Code and DBRef fall through as generic objects and render poorly. If a field is coming back as an object you did not expect, that list is where to look first.

Writes return an acknowledgement summary rather than documents - insertedId, modifiedCount, deletedCount - and rowCount is the row count or, for a write, the affected count.

The field names you address in a filter come from a schema that was inferred, not read from a catalog. getSchema() samples the first 100 documents per collection, expands subdocuments to dotted paths three levels deep, leaves arrays named and closed, and caps the list at 200 fields per collection. A field that appears only outside that sample does not appear in the tree. That is a different kind of knowledge from an information_schema read, and writing a filter against it deserves the same suspicion you would give any sample.