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 ANDMultiple 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 ofTwo 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 valueRead 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 elementsThe 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;
$ortakes complete filter documents. - Two conditions on one field go in one nested document — that is how ranges work.
$nematches missing fields too. Pair it with$existswhen that matters.{ tags: "x" }matches any element;{ tags: ["x"] }matches the whole array.- Use
$elemMatchwhen several conditions must hold for the same array element. $exprcompares two fields; a normal filter cannot.- Only anchored regexes use an index. Escape anything a user typed.