MongoDB – Query Operators

November 26, 20246 min readUpdated 8/24/2026

A MongoDB filter is a document, and the operators inside it are how you say anything more interesting than “equals”. There are a few dozen; you will use about twelve. This lesson is those twelve, plus the three behaviours that surprise people — missing fields, arrays, and what happens when a filter accidentally matches everything.

The shape of a filter

Every filter is { field: condition }. When the condition is a plain value it means equality; when it is a document whose keys start with $, those are operators:

{ status: "PUBLISHED" }                      // equals
{ "stats.views": { $gt: 1000 } }             // greater than
{ status: "PUBLISHED", "stats.views": { $gt: 1000 } }   // both — an implicit AND

Multiple fields in one document are ANDed. That covers most queries, and it is why $and is rarer than you would expect.

Comparison

{ "stats.views": { $gt: 1000 } }          // >
{ "stats.views": { $gte: 1000 } }         // >=
{ "video.durationSeconds": { $lt: 30 } }  // <
{ "video.durationSeconds": { $lte: 30 } } // <=
{ status: { $ne: "PUBLISHED" } }          // !=
{ status: { $in: ["DRAFT", "SCHEDULED"] } }     // any of
{ status: { $nin: ["ARCHIVED"] } }              // none of

Two conditions on the same field go in the same document, which is how you express a range:

db.reels.find({ "video.durationSeconds": { $gte: 20, $lte: 40 } })

Comparisons only ever match values of a comparable type. That is the string-versus-number problem from lesson 3 again, and it is worth internalising here: { views: { $gt: 5 } } does not match views: "12", because a string is not a number that happens to look like one.

Missing versus null — the one that catches everyone

$ne and $nin match documents where the field is absent, and { field: null } matches both explicit nulls and missing fields. These are different questions and the syntax hides it:

db.reels.countDocuments({ scheduledFor: null })                  // 15 — null OR missing
db.reels.countDocuments({ scheduledFor: { $exists: false } })    // 15 — missing only
db.reels.countDocuments({ scheduledFor: { $type: "null" } })     // 0  — explicitly null only
db.reels.countDocuments({ scheduledFor: { $ne: null } })         // 1  — has a real value

Read those four numbers carefully, because they say something you might not expect. Fifteen reels match scheduledFor: null, and all fifteen match $exists: false — while none of them is explicitly null. The field is simply not there.

That is not an accident of this dataset. Most object mappers, Spring Data included, omit a null field rather than storing it, so “the value is null” and “there is no such field” end up being the same state on disk. { field: null } deliberately matches both, which is convenient right up until you need to tell them apart — and then $exists and $type are the only things that can.

The same conflation makes $ne wider than it looks. { status: { $ne: "PUBLISHED" } } returns drafts, archived reels and any document with no status field at all. In a schemaless store that last group is real. When you mean “has a value and it is not this”, say so:

{ status: { $exists: true, $ne: "PUBLISHED" } }

Logical operators

$or is the one you actually need. It takes an array of complete filter documents:

db.reels.find({
  $or: [
    { status: "PUBLISHED" },
    { status: "SCHEDULED", scheduledFor: { $lte: new Date() } }
  ]
})

$and is only needed when you want two conditions with the same operator on the same field, since a document cannot have duplicate keys. $nor exists and is almost always clearer rewritten. One performance note worth carrying: an $or can use a different index for each branch, but only if every branch has one — a single unindexed branch drags the whole query into a collection scan.

Arrays

Array queries are where MongoDB is genuinely different, and the default behaviour is the one you usually want: a filter on an array field matches if any element matches.

db.reels.countDocuments({ tags: "motorsport" })         // 3 — any element equals
db.reels.countDocuments({ tags: ["motorsport"] })       // 0 — the WHOLE array, exactly
db.reels.countDocuments({ tags: { $all: ["motorsport", "overtake"] } })   // 1 — contains both
db.reels.countDocuments({ tags: { $size: 3 } })         // 16 — exactly three elements

The second line is the trap. Passing an array as the value is an equality test against the entire array, order included — almost never what was meant.

$elemMatch matters when the array holds documents and you need several conditions to hold for the same element. Without it, the conditions can be satisfied by different elements:

// "has a score over 8 AND has a judge named Ada" — possibly two different entries
db.clips.find({ "scores.value": { $gt: 8 }, "scores.judge": "Ada" })

// "has an entry where Ada scored over 8" — one entry, both conditions
db.clips.find({ scores: { $elemMatch: { value: { $gt: 8 }, judge: "Ada" } } })

The first query is a real bug that returns plausible results, which is the worst kind.

Element and evaluation operators

{ publishedAt: { $exists: true } }             // the field is present
{ "stats.views": { $type: "long" } }           // BSON type check
{ title: { $regex: "final", $options: "i" } }  // pattern, case-insensitive
{ $expr: { $gt: ["$stats.likes", "$stats.comments"] } }   // compare two fields

$expr is the one people miss. An ordinary filter compares a field to a constant; there is no way to say “likes greater than comments” without it, because the right-hand side has to be a value. $expr lets you use aggregation expressions in a plain find, at the cost of not being able to use an index for that comparison.

$regex is worth a warning. An anchored prefix pattern like /^final/ can use an index. Anything else — and especially a leading wildcard such as /final/ — scans every document. That is fine on an admin screen over a few thousand records and disastrous on a public endpoint. ReelCMS uses exactly that trade deliberately: its admin filter uses a regex so it can match as you type, while its public search uses a text index. The regex is also escaped before use, because a user typing (( into a search box should not be able to throw a pattern-syntax error:

String safe = java.util.regex.Pattern.quote(q.trim());
query.addCriteria(new Criteria()
        .orOperator(
                Criteria.where("title").regex(safe, "i"),
                Criteria.where("tags").regex(safe, "i"),
                Criteria.where("creator.displayName").regex(safe, "i")));

Checking what a query actually did

When a filter returns the wrong count, three checks find the cause almost every time:

db.reels.find(filter).explain("executionStats").executionStats.totalDocsExamined
db.reels.aggregate([{ $group: { _id: { $type: "$status" }, n: { $sum: 1 } } }])
db.reels.countDocuments({ status: { $exists: false } })

They answer, in order: did it use an index, is the field the type I think it is, and are there documents missing the field entirely. Most “the query is wrong” bugs are one of those three, and none of them announce themselves.

The short version

  • Fields in one filter document are ANDed; $or takes complete filter documents.
  • Two conditions on one field go in one nested document — that is how ranges work.
  • $ne matches missing fields too. Pair it with $exists when that matters.
  • { tags: "x" } matches any element; { tags: ["x"] } matches the whole array.
  • Use $elemMatch when several conditions must hold for the same array element.
  • $expr compares two fields; a normal filter cannot.
  • Only anchored regexes use an index. Escape anything a user typed.