Lesson 6 of 18

Query Operators

Where an Operator Goes

A query operator is a key beginning with $ that sits inside the value, not next to the field name. { price: { $gt: 500 } } reads as "the field price, with the condition greater-than 500 applied to it". Getting this shape wrong is the most common syntax error of the week; { $gt: { price: 500 } } is not a valid filter.

Two operators on the same field go into the same inner object and both must hold, which gives you ranges for free. { price: { $gte: 1000, $lte: 5000 } } means between one and five thousand rupees inclusive. Operators on different fields go side by side at the top level, and those are ANDed too.

Example
// Correct shape
db.products.find({ price: { $gt: 500 } })

// A range: two conditions on one field
db.products.find({ price: { $gte: 1000, $lte: 5000 } })

// Different fields — implicit AND
db.products.find({ category: "Electronics", price: { $lt: 20000 }, stock: { $gt: 0 } })

Comparison Operators

These eight cover most filtering you will ever write. $eq and $ne test equality and inequality; $gt, $gte, $lt and $lte compare ordered values such as numbers and dates; $in and $nin test membership of a list. $eq is rarely written out, because a bare value already means equality.

$in deserves special mention because it replaces a pile of $or clauses. "Any of these three categories" is one short filter, and it uses an index cleanly. It also works against array fields: if tags is an array, { tags: { $in: ["sale", "new"] } } matches a product carrying either tag.

Now the trap. MongoDB can compare values of different BSON types, and it does so using a fixed type order in which every number sorts before every string. So if some documents stored price: 799 and others stored price: "799", a query for { price: { $gt: 500 } } silently ignores every string one. No error, no warning — just a quietly incomplete answer. This is why the lesson on inserting insisted on storing the right type from the start.

Example
db.products.find({ price: { $ne: 0 } })
db.products.find({ stock: { $lte: 5 } })                 // running low
db.orders.find({ placedAt: { $gte: ISODate("2026-01-01") } })

// $in — one filter instead of three $or branches
db.products.find({ category: { $in: ["Electronics", "Furniture", "Books"] } })
db.products.find({ category: { $nin: ["Clearance"] } })

// The mixed-type trap
db.products.insertMany([{ price: 799 }, { price: "899" }])
db.products.find({ price: { $gt: 500 } })   // returns only the 799 document
Notes
  • $ne and $nin describe what you do not want, so an index cannot jump straight to the answer the way it can for equality — the server ends up examining most of the index. Where you can, phrase the filter positively with $in instead.

Logical Operators

$or takes an array of filters and matches a document if any of them holds. $nor matches if none of them holds. $not negates a single operator expression on one field. $and also exists, but you need it far less often than you might expect, because separate fields in a filter are already ANDed together.

The case where $and is genuinely required is when you need the same operator on the same field twice, or two independent $or groups in one query. A JavaScript object cannot hold two keys with the same name, so { $or: [...], $or: [...] } silently loses the first one. Wrapping both in $and is the correct way to express it.

A practical example: show discounted items — either an explicit sale flag or a price under 500 — but only from categories the customer is browsing. The category condition must apply to every branch of the $or, and writing it as a sibling key does exactly that.

Example
// Either condition, within one category
db.products.find({
  category: "Electronics",
  $or: [ { onSale: true }, { price: { $lt: 500 } } ]
})

// $nor — neither of these
db.products.find({ $nor: [ { stock: 0 }, { discontinued: true } ] })

// $not negates an operator on one field
db.products.find({ price: { $not: { $gt: 5000 } } })

// Two independent OR groups need $and, because a key cannot repeat
db.products.find({
  $and: [
    { $or: [ { category: "Electronics" }, { category: "Books" } ] },
    { $or: [ { onSale: true }, { rating: { $gte: 4 } } ] }
  ]
})
Notes
  • { price: { $not: { $gt: 5000 } } } also matches documents that have no price field at all, because a missing field cannot satisfy $gt. Negation and missing fields interact in ways that surprise people — test negative filters against a document that lacks the field.

Existence, Type, and the null Trap

Because MongoDB does not require every document to carry every field, you often need to ask whether a field is there at all. $exists answers that, and $type asks what kind of value a field holds — useful when auditing a collection that has grown untidy.

Here is the trap that catches everybody once. { phone: null } does not mean "where phone is null". It matches documents where phone holds the value null and documents that have no phone field at all. If you specifically want one or the other, you must say so with $exists. Combining $type: "null" with the filter is the precise way to catch only real nulls.

$type is also the fastest way to find the mixed-type mess described earlier. Ask for every document where price is a string, and you have your list of records to repair before that $gt query starts telling the truth again.

Example
db.users.find({ phone: { $exists: true } })    // field is present
db.users.find({ phone: { $exists: false } })   // field is absent

// The trap
db.users.find({ phone: null })                 // null values AND missing fields
db.users.find({ phone: { $type: "null" } })    // only genuine nulls
db.users.find({ phone: { $exists: true, $ne: null } })  // present and not null

// Audit a collection for the wrong type
db.products.find({ price: { $type: "string" } })
db.products.countDocuments({ price: { $type: "number" } })
Notes
  • $type: "number" is an alias that matches every numeric BSON type at once — double, int, long and decimal — which is usually what you mean when checking whether a field is a number.

Array Operators, and the Cross-Element Bug

Querying an array field for a single value already works without any operator: { tags: "sale" } matches if the array contains that value. Three operators handle the harder cases. $all requires every listed value to be present. $size matches on the exact length of the array. $elemMatch requires that one single element satisfies several conditions at once.

That last one exists because of a genuinely surprising default. Imagine a student document with an array of subject results. The filter { "results.subject": "Maths", "results.marks": { $gt: 90 } } looks like "scored above 90 in Maths" — but MongoDB checks each condition against the array as a whole. A student who took Maths and scored 55, and also scored 95 in Physics, matches: one element satisfied the first condition and a different element satisfied the second. $elemMatch forces both conditions onto the same element and gives the answer you meant.

$size has its own limitation: it takes an exact number and nothing else, so there is no way to ask for "arrays with more than three elements" with it. If you need range queries on array length, store the length as its own field and index that.

Example
db.products.find({ tags: "sale" })                        // contains "sale"
db.products.find({ tags: { $all: ["sale", "electronics"] } })  // contains both
db.products.find({ tags: { $size: 3 } })                  // exactly 3 tags

// Wrong: conditions can be satisfied by different array elements
db.students.find({ "results.subject": "Maths", "results.marks": { $gt: 90 } })

// Right: one element must satisfy both
db.students.find({
  results: { $elemMatch: { subject: "Maths", marks: { $gt: 90 } } }
})
Notes
  • $elemMatch is only needed when you have two or more conditions on the same array element. For a single condition, plain dot notation is correct and simpler.

Pattern Matching and Comparing Two Fields

$regex matches text against a regular expression, and in mongosh you may also write the pattern as a JavaScript literal such as /^an/i. It is genuinely useful for prefix lookups — an autocomplete box, for instance — but it comes with a performance rule you should learn now. A pattern anchored at the start of the string, like /^Ana/, can use an index and stay fast. Anything else, including a case-insensitive search or one that looks for text in the middle, forces MongoDB to examine every candidate document.

For real search over sentences and paragraphs, regular expressions are the wrong tool anyway — they do not understand word boundaries, stemming or relevance. Create a text index and use $text, which handles those properly. A collection may have at most one text index, but that index can cover several fields.

Finally, $expr lets a query compare two fields of the same document, which ordinary operators cannot do. "Products whose stock has fallen below their own reorder level" is impossible to express as a plain filter, because the value you are comparing against is different in every document. Inside $expr, field names are written with a $ prefix to mean "the value of this field".

Example
// Prefix search — index-friendly
db.users.find({ name: { $regex: "^Ana" } })
db.users.find({ name: /^Ana/ })              // same thing in mongosh

// Case-insensitive or mid-string — correct, but scans
db.users.find({ name: { $regex: "sharma", $options: "i" } })

// Proper text search needs a text index first
db.articles.createIndex({ title: "text", body: "text" })
db.articles.find({ $text: { $search: "mongodb indexing" } })

// Compare two fields of the same document
db.products.find({ $expr: { $lt: ["$stock", "$reorderLevel"] } })
Notes
  • Never build a regular expression by pasting in user input directly. A pattern such as .* typed into a search box turns your query into a full scan, and cleverly crafted patterns can take a very long time to evaluate. Escape the input, or restrict what characters you accept.
Ask AI