1. Query Operators: Beyond Exact Matches
A plain { field: value } filter only matches exact values.
Real queries usually need comparisons, ranges, and matching against a set —
MongoDB's query operators (always starting with $) handle all of
that inside the same find call.
All examples query a products collection like this:
{
"_id": ObjectId("..."),
"name": "Wireless Mouse",
"category": "Electronics",
"price": 799,
"inStock": true,
"tags": ["accessories", "wireless", "office"]
}
// Comparison operators
db.products.find({ price: { $gt: 500 } }) // greater than
db.products.find({ price: { $gte: 500, $lte: 1500 } }) // range
db.products.find({ category: { $ne: "Electronics" } }) // not equal
// Match against a set of values
db.products.find({ category: { $in: ["Electronics", "Office"] } })
// Logical operators
db.products.find({
$and: [{ inStock: true }, { price: { $lt: 1000 } }]
})
db.products.find({
$or: [{ category: "Electronics" }, { price: { $gt: 2000 } }]
})
// Array operator -- documents where tags contains "wireless"
db.products.find({ tags: "wireless" })
// Array operator -- documents where tags contains ALL of these
db.products.find({ tags: { $all: ["wireless", "office"] } })
You don't need a special operator to check whether an array field contains a value — { tags: "wireless" } matches any document where "wireless" appears anywhere in the tags array. MongoDB treats this as implicit behavior for array fields.
2. Projections: Shaping What Comes Back
A second argument to find controls which fields are returned —
this matters for both readability and performance once documents get large.
// Include ONLY name and price (_id is included by default unless excluded)
db.products.find({}, { name: 1, price: 1 })
// Exclude _id explicitly
db.products.find({}, { name: 1, price: 1, _id: 0 })
// Exclude specific fields, return everything else
db.products.find({}, { tags: 0 })
You can't mix inclusion and exclusion in the same projection (except for
_id, which is the one field allowed to be excluded alongside an
inclusion projection) — pick one style per query.
3. Indexes: Why Queries Get Slow & How to Fix It
Without an index, MongoDB performs a collection scan — it checks every single document to find matches. That's fine at a few hundred documents and genuinely painful at a few million. An index lets MongoDB jump straight to matching documents instead.
// Create an ascending index on price
db.products.createIndex({ price: 1 })
// Compound index -- order matters: queries on category alone, or
// category+price together, can use this index; price alone cannot
db.products.createIndex({ category: 1, price: -1 })
// List every index on the collection
db.products.getIndexes()
// Remove an index you no longer need
db.products.dropIndex({ price: 1 })
Each index makes reads on that field faster but makes every insert, update and delete slightly slower, since MongoDB has to keep the index up to date too. Index the fields you actually filter and sort on regularly, not every field in the document.
4. The Aggregation Pipeline: $match, $group, $sort, $project
The aggregation pipeline processes documents through an ordered sequence of
stages, each one transforming the output of the stage before it — this is
MongoDB's equivalent of SQL's GROUP BY, and it can do considerably
more.
db.products.aggregate([
// Stage 1: filter, same idea as find()'s query argument
{ $match: { inStock: true } },
// Stage 2: group by category, computing a total and count per group
{
$group: {
_id: "$category",
totalValue: { $sum: "$price" },
productCount: { $sum: 1 },
avgPrice: { $avg: "$price" }
}
},
// Stage 3: sort groups by total value, highest first
{ $sort: { totalValue: -1 } },
// Stage 4: reshape the output fields
{
$project: {
category: "$_id",
_id: 0,
totalValue: 1,
productCount: 1,
avgPrice: { $round: ["$avgPrice", 2] }
}
}
])
Read a pipeline top to bottom, one stage at a time: $match narrows
down documents first (do this as early as possible — it's much cheaper to filter
before grouping than after), $group collapses matching documents into
one output document per distinct _id value, $sort
orders the grouped results, and $project reshapes the final output.
5. explain() & Reading a Query Plan
Don't guess whether a query is using an index — explain() tells you
exactly what MongoDB did to execute it.
db.products.find({ category: "Electronics" }).explain("executionStats")
// Key fields to check in the output:
// stage: "COLLSCAN" -> scanned every document (no usable index)
// stage: "IXSCAN" -> used an index -- what you want for large collections
// totalDocsExamined -> how many documents it actually looked at
// totalKeysExamined -> how many index entries it looked at
// executionTimeMillis -> how long it took
If totalDocsExamined is dramatically higher than the number of documents actually returned, the query is scanning far more data than necessary — usually a sign it's missing an index that matches its filter, or the index it has doesn't fully cover the query.
6. Hands-on Exercise
Query and summarize a small product catalog
Put query operators, an index, and an aggregation pipeline to work against real data.
Requirements:
- Insert at least 10 products into a
productscollection, spanning 3+ categories, withprice,inStockand atagsarray on each. - Write a query returning in-stock products priced between 500 and 2000, using
$gte/$lteand$and. - Write a query using
$into return products from any two of your categories at once. - Create an index on
category, then runexplain("executionStats")on a category-filtered query before and after creating it — confirm the stage changes fromCOLLSCANtoIXSCAN. - Write an aggregation pipeline that groups by category, computing count and average price per category, sorted by average price descending.
- Add a
$matchstage at the start of that pipeline restricting it toinStock: trueproducts only, and confirm the totals change accordingly.
If your aggregation totals look wrong, check the order of your stages — a $match placed after $group filters the already-grouped summary rows, not the original documents, which usually isn't what you meant.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
What does { tags: "wireless" } match when tags is an array field?
What does { tags: "wireless" } match when tags is an array field?
Any document where "wireless" appears anywhere inside the tags array — MongoDB automatically checks array elements for a plain equality match against an array field, without needing a special operator.
Q2
What does a COLLSCAN in an explain() result tell you?
What does a COLLSCAN in an explain() result tell you?
MongoDB scanned every document in the collection to find matches, meaning no usable index existed for that query. On a small collection this is harmless; on a large one it's usually the first thing to fix, typically by adding an index on the field(s) being filtered.
Q3
Why should a $match stage generally come as early as possible in an aggregation pipeline?
Why should a $match stage generally come as early as possible in an aggregation pipeline?
Each stage processes whatever the previous stage output. An early $match shrinks the number of documents every later stage has to process (and can use an index, the way a find query would), while a $match placed after $group instead filters the summarized results — a completely different, usually unintended, operation.
Q4
Why not just add an index to every field in a collection?
Why not just add an index to every field in a collection?
Every index speeds up reads on that field but slows down every write, since MongoDB has to update each index whenever a document is inserted, updated or deleted. Indexes should be added deliberately for fields that are actually filtered or sorted on frequently, not applied blanket-wide.