Query Operators ($eq, $gt, $lt, $in)

Query operators are the special $-prefixed keys you place inside a query document to ask MongoDB for more than a plain equality check — things like “greater than,” “less than,” or “any value from this set.” Without them, find() can only match exact field values, which makes operators essential for almost any real-world query: filtering by price range, finding recent orders, or matching one of several allowed categories. This lesson covers the core comparison operators — $eq, $gt, and $lt — along with their close relatives $gte, $lte, $ne, and the membership operators $in / $nin, plus how they interact with indexes.

Overview / How it works

A MongoDB document is stored as BSON (Binary JSON), a binary-encoded superset of JSON that adds types like ObjectId, Date, and 64-bit integers that plain JSON doesn’t have. When you call db.products.find({ price: 100 }), MongoDB compares the BSON value of each document’s price field against the BSON value 100. That query shorthand — { field: value } — is actually an implicit $eq. Writing { price: 100 } and { price: { $eq: 100 } } produce identical results; the shorthand is just more common because it reads cleaner.

Every other comparison works the same way: you replace the plain value with an operator document, { field: { $operator: value } }. MongoDB defines a strict BSON type-ordering (roughly: null < numbers < strings < objects < arrays < booleans < dates < ObjectIds), and comparisons like $gt/$lt only make sense — and only match — within the same type family by default. Comparing a number field with $gt: "50" (a string) will not match numeric values, because MongoDB compares by BSON type first.

Under the hood, the query planner looks at the operators you used and decides whether an index can serve the query. Equality ($eq) and membership ($in) can use an index directly via an index seek. Range operators ($gt, $lt, $gte, $lte) can also use an index, scanning a contiguous range of index entries. Negation operators ($ne, $nin) are the exception: because they describe everything except a value or set, an index can rarely narrow the search much, so MongoDB often falls back to scanning most or all of the index (or the whole collection). We’ll see this difference with explain() later in the lesson.

Syntax

The general shape of a comparison query is:

db.collection.find({ field: { $operator: value } });
Operator Meaning Example
$eq Equal to value { status: { $eq: "active" } }
$ne Not equal to value { status: { $ne: "archived" } }
$gt Greater than value { price: { $gt: 50 } }
$gte Greater than or equal to value { price: { $gte: 50 } }
$lt Less than value { price: { $lt: 200 } }
$lte Less than or equal to value { price: { $lte: 200 } }
$in Matches any value in an array { category: { $in: ["Toys", "Books"] } }
$nin Matches none of the values in an array { category: { $nin: ["Discontinued"] } }

You can combine multiple operators on the same field — MongoDB treats them as an implicit AND — for example { price: { $gt: 50, $lt: 200 } } means “price is greater than 50 and less than 200.”

Examples

These examples use a products collection in an online store database. Assume each document looks like { name: "Wireless Mouse", category: "Electronics", price: 25, stock: 40 }.

use online_store

First, the implicit and explicit forms of $eq return exactly the same documents:

// Implicit equality (shorthand)
db.products.find({ category: "Electronics" });

// Explicit $eq (equivalent)
db.products.find({ category: { $eq: "Electronics" } });

Output:

[
  { _id: ObjectId("66a1...01"), name: "Wireless Mouse", category: "Electronics", price: 25, stock: 40 },
  { _id: ObjectId("66a1...02"), name: "4K Monitor", category: "Electronics", price: 310, stock: 12 }
]

Both queries scan the collection (or an index) looking for documents where category exactly equals the string "Electronics". Use the explicit form when you’re building a query dynamically, or when you need to pair $eq with other operators like $exists in the same operator document.

Next, a range query combining $gt and $lt to find mid-priced products:

db.products.find({ price: { $gt: 50, $lt: 200 } });

Output:

[
  { _id: ObjectId("66a1...03"), name: "Bluetooth Speaker", category: "Electronics", price: 89, stock: 22 },
  { _id: ObjectId("66a1...04"), name: "Desk Lamp", category: "Home", price: 145, stock: 8 }
]

MongoDB evaluates both bounds as a single range and returns only documents whose price falls strictly between 50 and 200. If you wanted the endpoints included, you’d swap in $gte and $lte instead.

Finally, $in lets you match any of several category values without chaining multiple $or clauses:

db.products.find({ category: { $in: ["Electronics", "Toys", "Books"] } }).sort({ price: 1 });

Output:

[
  { _id: ObjectId("66a1...01"), name: "Wireless Mouse", category: "Electronics", price: 25, stock: 40 },
  { _id: ObjectId("66a1...05"), name: "Building Blocks", category: "Toys", price: 30, stock: 60 },
  { _id: ObjectId("66a1...03"), name: "Bluetooth Speaker", category: "Electronics", price: 89, stock: 22 },
  { _id: ObjectId("66a1...02"), name: "4K Monitor", category: "Electronics", price: 310, stock: 12 }
]

$in is logically equivalent to { $or: [{ category: "Electronics" }, { category: "Toys" }, { category: "Books" }] }, but it’s shorter, easier to build from a dynamic array of values, and MongoDB optimizes it as a set of index seeks on a single field rather than evaluating separate branches.

How it works step by step

To see how the query planner actually executes these queries, run explain("executionStats"):

db.products.find({ price: { $gt: 500 } }).explain("executionStats");

Output (trimmed):

{
  executionStats: {
    executionStages: { stage: "COLLSCAN" },
    totalDocsExamined: 50000,
    totalKeysExamined: 0,
    nReturned: 340
  }
}

COLLSCAN means MongoDB walked every document in the collection to check the condition — expensive on a large collection. Create an index on price and MongoDB switches strategy:

db.products.createIndex({ price: 1 });
db.products.find({ price: { $gt: 500 } }).explain("executionStats");

Output (trimmed):

{
  executionStats: {
    executionStages: { stage: "FETCH", inputStage: { stage: "IXSCAN" } },
    totalDocsExamined: 340,
    totalKeysExamined: 340,
    nReturned: 340
  }
}

Now the planner does an IXSCAN: it seeks directly to the first index entry greater than 500, walks forward through the sorted index (B-tree) only as far as it needs to, then fetches just the matching documents. This is why $gt/$lt/$in benefit enormously from indexes, while $ne/$nin usually can’t — there’s no contiguous range of “not equal to X” in a sorted index, so the planner typically has to examine most of it anyway.

Common Mistakes

Mistake 1: Comparing ObjectId to a raw string

_id fields are stored as ObjectId, not strings. A value pulled from a URL parameter or form is always a plain string, so comparing it directly silently returns nothing:

// Wrong: productId is a plain string, _id is stored as ObjectId
const productId = "64f1a2b3c4d5e6f7a8b9c0d1";
db.products.find({ _id: productId }); // returns no documents

Convert it explicitly before querying:

const productId = "64f1a2b3c4d5e6f7a8b9c0d1";
db.products.find({ _id: new ObjectId(productId) });

Mistake 2: Using $eq with an array when you meant $in

It’s tempting to pass an array straight to $eq, expecting “match any of these.” Instead, $eq checks whether the field’s value is exactly that array:

// Wrong: category is a string field, this looks for an exact array match
db.products.find({ category: { $eq: ["Electronics", "Toys"] } }); // returns nothing

$in is the operator that means “any of these values”:

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

Best Practices

  • Use the implicit equality shorthand ({ field: value }) for simple checks; reserve explicit $eq for dynamically built queries or when combining with other operators.
  • Create an index on any field you regularly query with $gt, $lt, $gte, $lte, or $in once the collection grows past a few thousand documents, and confirm it’s used with explain("executionStats").
  • Prefer $in over a chain of $or clauses on the same field — it’s more readable and lets the planner use a single index efficiently.
  • Use $ne and $nin sparingly on large, indexed collections; they usually can’t narrow an index range the way positive comparisons can.
  • Always convert values from external input (URL params, form fields) to the correct BSON type — ObjectId, Number, or Date — before comparing them.
  • Keep $in arrays reasonably sized; a list with thousands of values degrades performance similarly to a huge $or chain.

Practice Exercises

  • Given a db.employees collection with a numeric salary field, write a query that returns employees earning between 50,000 and 90,000 inclusive (hint: use $gte and $lte).
  • Given a db.orders collection with a status field (values like "pending", "processing", "shipped", "delivered"), write one query that returns every order that is not yet "delivered", using $nin. Then run it through explain("executionStats") and note whether it uses an index.
  • A colleague reports that db.products.find({ _id: req.params.id }) always returns null even though the product exists. Identify the bug and rewrite the query correctly.

Summary

  • { field: value } is shorthand for { field: { $eq: value } } — both perform an exact equality match.
  • $gt, $gte, $lt, and $lte compare values within the same BSON type and combine as an implicit AND when used together on one field.
  • $in matches any value from a given array; $nin matches none of them.
  • $eq, $gt/$lt, and $in can all use an index; $ne/$nin generally cannot narrow an index range effectively.
  • Use explain("executionStats") to confirm whether a query performs an IXSCAN (index) or a COLLSCAN (full collection scan).
  • Always convert externally supplied values (especially _id strings) to their correct BSON type before comparing.