Type something to search...
A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising amount of ground, but real applications quickly need more. Products under a certain price. Orders placed in the last week. Users who have a phone number on file. Invoices where the amount paid is less than the amount due.

MongoDB handles all of these with query operators: special keys starting with $ that you place inside a filter document. There's no separate query language to learn. Every filter is still a document, which means you can build filters programmatically, pass them around as objects, and compose them without string concatenation.

This guide covers every query operator you're likely to use, grouped by category: comparison, logical, element, evaluation, and array operators, plus a brief look at the specialized ones. Along the way, you'll see the subtle behaviors (missing fields, arrays, types) that cause most "why doesn't this match?" moments, and how operators interact with indexes.

Sample Data

The examples use a small products collection. Paste this into mongosh to follow along:

use shop
db.products.drop()
db.products.insertMany([
  { _id: 1, name: "Trail Runner", category: "shoes", price: 120, stock: 14, tags: ["running", "outdoor"], ratings: [5, 4, 5], sizes: [{ size: 9, qty: 4 }, { size: 10, qty: 10 }] },
  { _id: 2, name: "City Sneaker", category: "shoes", price: 85, stock: 0, tags: ["casual"], ratings: [3, 4], sizes: [{ size: 9, qty: 0 }, { size: 11, qty: 0 }] },
  { _id: 3, name: "Rain Shell", category: "jackets", price: 150, stock: 6, tags: ["outdoor", "waterproof"], ratings: [5], discount: 0.1 },
  { _id: 4, name: "Wool Beanie", category: "accessories", price: 25, stock: 40, tags: ["winter"], ratings: [], discount: null },
  { _id: 5, name: "Hiking Boot", category: "shoes", price: 180, stock: 3, tags: ["outdoor", "waterproof", "hiking"], ratings: [4, 4, 5, 3], sizes: [{ size: 10, qty: 3 }] },
  { _id: 6, name: "Fleece Pullover", category: "jackets", price: "65", stock: 12, tags: ["winter", "casual"] }
])

Note that product 6 has its price stored as a string. That's deliberate; it will come up later.

Comparison Operators

OperatorMatches values that are...
$eqequal to a value
$nenot equal to a value
$gtgreater than a value
$gtegreater than or equal to a value
$ltless than a value
$lteless than or equal to a value
$inequal to any value in an array
$ninnot equal to any value in an array

Ranges

Combine operators on the same field to build a range:

db.products.find({ price: { $gte: 80, $lte: 150 } }, { name: 1, price: 1 });
[
  { _id: 1, name: 'Trail Runner', price: 120 },
  { _id: 2, name: 'City Sneaker', price: 85 },
  { _id: 3, name: 'Rain Shell', price: 150 }
]

The Fleece Pullover isn't there, even though "65" is below 150. Comparison operators only match values of the same type bracket (numbers compare with numbers, strings with strings). A numeric range never matches a string, which is exactly why storing numbers as strings causes silent bugs.

$eq is what a plain { price: 120 } does implicitly. You'll rarely write it explicitly, but it's useful when you need to be unambiguous, for example when the value itself might be an object that starts with $, or when building filters from user input.

$in and $nin

$in matches if the field equals any listed value:

db.products.find({ category: { $in: ["jackets", "accessories"] } });

It's the cleaner alternative to an $or of equality checks on the same field, and it uses indexes efficiently. Keep $in lists reasonably sized; lists with tens of thousands of values are slow to parse and plan.

When the field is an array, $in matches if any element equals any listed value:

db.products.find({ tags: { $in: ["hiking", "winter"] } }, { name: 1 });
// Wool Beanie, Hiking Boot, Fleece Pullover

The Missing-Field Trap with $ne and $nin

$ne and $nin match documents where the field doesn't exist at all:

db.products.find({ discount: { $ne: 0.1 } }, { name: 1 });

This returns every product except the Rain Shell, including the ones with no discount field. That's logically correct ("the discount is not 0.1"), but often not what you meant. If you want only documents that have the field, say so:

db.products.find({ discount: { $exists: true, $ne: 0.1 } });

Negation operators also use indexes poorly, because they tend to match most of the collection. If you find yourself filtering with $ne on a hot path, consider whether a positive condition can express the same thing.

Logical Operators

Implicit AND

Multiple conditions in one filter are combined with AND:

db.products.find({ category: "shoes", stock: { $gt: 0 } });

$or

$or takes an array of filter documents and matches if any of them match:

db.products.find(
  {
    $or: [{ stock: 0 }, { price: { $gt: 160 } }],
  },
  { name: 1 },
);
// City Sneaker, Hiking Boot

For $or to use indexes, every clause needs a usable index. If one branch can't use an index, MongoDB falls back to a collection scan for the whole query.

$and

Explicit $and is only necessary in two situations. The first is when you need two $or conditions in the same query, because a document can't have the same key twice:

db.products.find({
  $and: [
    { $or: [{ category: "shoes" }, { category: "jackets" }] },
    { $or: [{ stock: { $lt: 5 } }, { tags: "waterproof" }] },
  ],
});

The second is when you need the same operator twice on one field, which is rare. Otherwise, the implicit form is shorter and does the same thing.

$not and $nor

$not negates an operator expression for a single field:

// Price is not greater than 100 (includes missing and non-numeric prices!)
db.products.find({ price: { $not: { $gt: 100 } } });

Like $ne, $not matches documents where the field is missing or of a different type, so the Fleece Pullover's string price is included here. $not can also wrap a regular expression: { name: { $not: /^Trail/ } }.

$nor matches documents that fail every clause:

db.products.find({ $nor: [{ category: "shoes" }, { stock: { $gt: 10 } }] });
// Rain Shell

Element Operators

$exists

$exists: true matches documents that have the field, even if its value is null. $exists: false matches documents that don't have it:

db.products.find({ discount: { $exists: true } }, { name: 1, discount: 1 });
[
  { _id: 3, name: 'Rain Shell', discount: 0.1 },
  { _id: 4, name: 'Wool Beanie', discount: null }
]

Compare that with a query for null, which matches both explicit nulls and missing fields:

db.products.find({ discount: null }, { name: 1 });
// Trail Runner, City Sneaker, Wool Beanie, Hiking Boot, Fleece Pullover

This distinction trips people up constantly. Use { field: null } for "has no meaningful value", { field: { $exists: false } } for "the key is absent", and { field: { $type: "null" } } for "explicitly set to null".

$type

$type matches by BSON type, using either a string alias or a numeric code. It's the best tool for finding inconsistent data:

db.products.find({ price: { $type: "string" } }, { name: 1, price: 1 });
// { _id: 6, name: 'Fleece Pullover', price: '65' }

// Any numeric type: int, long, double, or decimal
db.products.find({ price: { $type: "number" } });

// Multiple types
db.products.find({ discount: { $type: ["double", "null"] } });

Evaluation Operators

$regex

$regex matches strings against a regular expression. You can use the operator form or a regex literal:

db.products.find({ name: { $regex: "^hik", $options: "i" } });
db.products.find({ name: /boot$/i });

Regex performance depends heavily on the pattern. A case-sensitive prefix match like /^Hik/ can use an index efficiently. A case-insensitive match or a pattern that isn't anchored at the start has to examine every index key or document. For case-insensitive lookups at scale, a collation index is usually a better fit, and for real search, use a text index or Atlas Search. There's a full treatment in Regular Expression Queries in MongoDB: Power and Pitfalls.

$expr

Standard operators compare a field against a constant. $expr lets you use aggregation expressions inside a query, which means you can compare fields to each other or compute values:

// Products whose discounted price is under 140
db.products.find({
  $expr: {
    $lt: [
      {
        $multiply: [
          { $toDouble: "$price" },
          { $subtract: [1, { $ifNull: ["$discount", 0] }] },
        ],
      },
      140,
    ],
  },
});

The $toDouble is there because of the Fleece Pullover's string price. Without it, $multiply throws an error the moment it reaches a non-numeric value, and the whole query fails. Inconsistent types hurt $expr even more than regular filters.

A more common real-world example compares two fields in the same document:

db.invoices.find({ $expr: { $lt: ["$amountPaid", "$amountDue"] } });

$expr is powerful but has limited index support. Equality and range comparisons against constants can use indexes in recent versions, but comparisons between two fields always evaluate per document. Use it for flexibility, then check explain() on large collections.

$mod

$mod matches when a number divided by a divisor has a given remainder:

db.products.find({ stock: { $mod: [2, 0] } }); // even stock counts

$jsonSchema

$jsonSchema matches documents against a JSON Schema. It's mostly used for collection validation, but it also works in queries, which makes it a handy way to find documents that would fail a validator you're about to add:

db.products.find({
  $nor: [
    {
      $jsonSchema: {
        required: ["price"],
        properties: { price: { bsonType: "number" } },
      },
    },
  ],
});
// finds the Fleece Pullover

$where (Avoid)

$where runs a JavaScript function against each document. It's slow, can't use indexes, and server-side JavaScript was deprecated in recent MongoDB versions. Anything $where can do, $expr can almost always do faster. Don't use it in new code.

Array Operators

Arrays deserve special attention, because many operators behave differently when the field is an array.

Implicit Element Matching

A condition on an array field matches if any element satisfies it:

db.products.find({ ratings: 3 }); // any rating equals 3
db.products.find({ ratings: { $lt: 4 } }); // any rating below 4

This gets surprising with multiple conditions:

db.products.find({ ratings: { $gt: 3, $lt: 5 } }, { name: 1, ratings: 1 });

You might expect this to find products with a rating strictly between 3 and 5 (in other words, a 4). But each condition can be satisfied by a different element. The Trail Runner has [5, 4, 5], so it matches. So does a hypothetical product with [2, 6]: 6 is greater than 3, and 2 is less than 5.

$elemMatch

$elemMatch requires a single element to satisfy all conditions at once:

db.products.find({ ratings: { $elemMatch: { $gt: 3, $lt: 5 } } });

It matters even more for arrays of sub-documents. Consider "products with size 9 in stock":

// Wrong: some element has size 9 AND some element has qty > 0
db.products.find({ "sizes.size": 9, "sizes.qty": { $gt: 0 } }, { name: 1 });
// Trail Runner here, but it would also match
// [{ size: 9, qty: 0 }, { size: 10, qty: 5 }]

// Right: one element has size 9 AND qty > 0
db.products.find(
  { sizes: { $elemMatch: { size: 9, qty: { $gt: 0 } } } },
  { name: 1 },
);
// Trail Runner

Whenever you have two or more conditions that must apply to the same array element, use $elemMatch. The deeper story on array queries is in Working with Arrays in MongoDB: Queries, Updates, and $elemMatch.

$all

$all matches arrays that contain every listed value, in any order:

db.products.find({ tags: { $all: ["outdoor", "waterproof"] } }, { name: 1 });
// Rain Shell, Hiking Boot

Compare that with an exact array match, which requires the same elements in the same order and nothing else:

db.products.find({ tags: ["outdoor", "waterproof"] }); // only Rain Shell

$size

$size matches arrays with an exact number of elements:

db.products.find({ ratings: { $size: 0 } }); // Wool Beanie

$size doesn't accept ranges. To find arrays with more than two elements, either check whether a given index exists, or use $expr:

db.products.find({ "ratings.2": { $exists: true } }); // 3+ ratings
db.products.find({
  $expr: { $gt: [{ $size: { $ifNull: ["$ratings", []] } }, 2] },
});

The first form is simpler and can use an index on ratings. If you query array lengths often, store a count field alongside the array and keep it updated with $inc.

Specialized Operators

A few operator families are important but deserve their own articles:

  • Geospatial: $near, $geoWithin, $geoIntersects, used with 2dsphere indexes for location queries.
  • Text: $text with $search, which requires a text index. For richer relevance and fuzzy matching, Atlas Search uses the $search aggregation stage instead.
  • Bitwise: $bitsAllSet, $bitsAnySet, $bitsAllClear, $bitsAnyClear, for fields that pack flags into integers.
  • Comments: $comment attaches a note to a query that shows up in logs and the profiler, which helps trace slow queries back to the code that issued them.
db.products.find({ category: "shoes", $comment: "product-list-page" });

Operators and Indexes

Operators differ widely in how well they use indexes. A rough guide:

OperatorIndex use
Equality, $inExcellent
$gt, $gte, $lt, $lteExcellent (range scan)
Anchored, case-sensitive $regexGood (prefix range scan)
$elemMatch, $allGood with a multikey index
$orGood only if every clause is indexed
$exists: trueModerate (sparse indexes help)
$ne, $nin, $not, $exists: falsePoor, usually scans most of the index
Unanchored or case-insensitive $regexPoor, examines every key
$expr comparing two fields, $whereNone, evaluated per document

When in doubt, run the query with .explain("executionStats") and compare totalDocsExamined with nReturned. If MongoDB examines thousands of documents to return ten, the query needs an index or a rewrite.

Common Mistakes

Putting a numeric range on a field with mixed types. Strings and numbers never compare with each other. Audit with $type and fix the data rather than writing queries that work around it.

Forgetting that $ne, $nin, and $not match missing fields. Add $exists: true when you only care about documents that have the field.

Using dot-notation conditions when you need $elemMatch. Separate conditions on an array of sub-documents can be satisfied by different elements. If the conditions describe one element, wrap them in $elemMatch.

Writing duplicate keys in one filter. { $or: [...], $or: [...] } silently keeps only the last $or in JavaScript. Wrap multiple $or clauses in $and.

Passing user input straight into filters. If a request body contains { "password": { "$ne": "" } } and you drop it into a query, you've been injected. Validate types, or wrap untrusted values in $eq.

Reaching for $where. It's slow and deprecated. Use $expr.

Conclusion

MongoDB's query operators turn plain filter documents into a full query language. Comparison operators handle ranges and membership, logical operators combine conditions, $exists and $type deal with the shape of your data, $regex and $expr cover pattern matching and computed conditions, and $elemMatch, $all, and $size handle arrays. Most surprises come from the same few sources: type brackets, missing fields, and conditions on arrays matching different elements.

For a next step, pick the slowest or most complicated filter in one of your projects, run it with .explain("executionStats"), and look at which operators it uses. Rewriting a single $ne or unanchored regex into a positive, indexable condition is often the easiest performance win you'll find.

Tags :
Share :

Related Posts

Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading
Atlas Search: Adding Full-Text Search to Your App Without Elasticsearch

Atlas Search: Adding Full-Text Search to Your App Without Elasticsearch

The traditional way to add good search to a MongoDB app goes like this: stand up an Elasticsearch or OpenSearch cluster, write a sync process that co

Continue Reading