
The Query You Actually Want to Write
You need to group documents by date, filter out inactive users, compute an average per group, and sort by that average. One query. That is what the aggregation pipeline is for.
If your application pulls thousands of documents over the network just to filter and sum them in JavaScript, you are doing the database's job in your application code. The aggregation pipeline pushes that work to MongoDB itself, so only the result crosses the network. On large collections that difference is usually the biggest win you can get.
This covers the three stages everyone uses ($match, $group, $sort), then the three that are actually interesting ($lookup, $unwind, $facet). Plus $setWindowFields because window functions are absurdly useful once you know they exist.
Pipeline Basics: How Stages Flow
Array of stage objects. Pass it to aggregate(). Output of stage one becomes input of stage two.
// A basic aggregation pipeline with two stages
db.orders.aggregate([
// Stage 1: Filter documents
{ $match: { status: "completed" } },
// Stage 2: Shape the output
{ $project: {
customerName: 1,
total: 1,
orderDate: 1,
_id: 0
}}
]);Order matters. $match first means fewer documents for every downstream stage. Stages can repeat — $match early, group, $match again on the grouped results. And the pipeline never modifies your collection. Returns a cursor, same as find().
Filtering with $match and $project
$match uses the same query syntax as find(). Same operators. Same patterns. Nothing new to learn.
$project is where it gets interesting. Not just showing and hiding fields — computed fields, renames, inline expressions. Here is a products collection query that filters in-stock electronics and adds a formatted price label:
db.products.aggregate([
// Only in-stock electronics
{ $match: {
category: "Electronics",
stock: { $gt: 0 }
}},
// Reshape the output with computed fields
{ $project: {
_id: 0,
name: 1,
priceLabel: {
$concat: [{ $literal: "$" }, { $toString: "$price" }]
},
discountedPrice: {
$round: [
{ $multiply: ["$price", 0.9] },
2
]
},
inStock: "$stock"
}}
]);priceLabel concatenates a dollar sign with the price. The $literal matters: any string that starts with $ is read as a field path, so a bare "$" makes the stage fail. discountedPrice applies a 10% discount and rounds to two decimals. All inside the database. Your application code receives the finished result.
Want computed fields without dropping existing ones? Use$addFields(aliased as$set) instead of$project. Keeps every field, appends the new ones.
Grouping and Accumulating with $group
SQL's GROUP BY with SUM(), AVG(), COUNT(). Monthly sales summary:
db.orders.aggregate([
{ $match: {
status: "completed",
orderDate: {
$gte: ISODate("2026-01-01"),
$lt: ISODate("2027-01-01")
}
}},
{ $group: {
_id: {
year: { $year: "$orderDate" },
month: { $month: "$orderDate" }
},
totalRevenue: { $sum: "$total" },
averageOrderValue: { $avg: "$total" },
orderCount: { $sum: 1 },
largestOrder: { $max: "$total" }
}},
// Sort by year and month ascending
{ $sort: { "_id.year": 1, "_id.month": 1 } }
]);
Accumulators worth knowing: $sum (set to 1 to count documents), $avg, $min/$max, $first/$last (useful after a $sort), $push (collects into array), $addToSet (unique values only).
_id: null treats everything as one group. Global totals across whatever survived the $match.
Joining Collections with $lookup
$lookup is basically a join. But worse in specific ways.
It is a left outer join only. Same database only. Cannot join across databases. The syntax requires four fields and you will forget which is which for the first dozen times: from (the other collection), localField (field in your current documents), foreignField (matching field in the foreign collection), as (name for the resulting array). Yes, array — it always produces an array, even for one-to-one relationships. You almost always need $unwind right after it to flatten that array into a single object.
But be careful. If a reference is broken and the array is empty, $unwind drops the entire document. Add preserveNullAndEmptyArrays: true if you need to keep unmatched documents.
Orders enriched with customer name and email:
db.orders.aggregate([
{ $match: { status: "completed" } },
// Join with the customers collection
{ $lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customerInfo"
}},
// $lookup returns an array; unwind to get a single object
{ $unwind: "$customerInfo" },
// Clean up the output
{ $project: {
orderId: "$_id",
total: 1,
orderDate: 1,
customerName: "$customerInfo.name",
customerEmail: "$customerInfo.email",
_id: 0
}}
]);
For more complex join logic, the pipeline form of $lookup. Instead of simple field matching, you define a sub-pipeline that filters, projects, and sorts the joined documents before they get embedded:
// Pipeline form of $lookup for complex joins
db.orders.aggregate([
{ $lookup: {
from: "reviews",
let: { orderId: "$_id" },
pipeline: [
{ $match: {
$expr: {
$and: [
{ $eq: ["$orderId", "$$orderId"] },
{ $gte: ["$rating", 4] }
]
}
}},
{ $project: { rating: 1, comment: 1, _id: 0 } }
],
as: "positiveReviews"
}}
]);let passes variables from the outer pipeline into the sub-pipeline. Especially useful when the foreign collection is large — narrow the join results before they get embedded instead of pulling every matching document and filtering afterward. On a collection with millions of reviews, the difference between the simple form and the pipeline form can be the difference between a query that finishes and one that times out.
Array Operations: $unwind, $addToSet, $push
MongoDB documents are full of arrays. Order items, tags, roles, activity logs.
$unwind explodes an array field into one document per element. Five items in the array? Five documents out. Then you can group, sort, and aggregate on individual elements.
Top-selling products across all orders, starting from a lineItems array:
db.orders.aggregate([
{ $match: { status: "completed" } },
// Flatten the lineItems array
{ $unwind: "$lineItems" },
// Group by product and calculate totals
{ $group: {
_id: "$lineItems.productId",
productName: { $first: "$lineItems.name" },
totalQuantity: { $sum: "$lineItems.quantity" },
totalRevenue: {
$sum: {
$multiply: [
"$lineItems.price",
"$lineItems.quantity"
]
}
},
orderIds: { $addToSet: "$_id" }
}},
// Sort by revenue descending
{ $sort: { totalRevenue: -1 } },
// Top 10 products
{ $limit: 10 }
]);$push vs $addToSet: push keeps duplicates, addToSet does not. Use addToSet for distinct order IDs, unique tags. Use push when duplicates matter — timestamps of repeated events, for instance.
Sometimes you do not want to flatten an array at all. $filter trims array elements in place:
// Keep only line items above $50 without unwinding
db.orders.aggregate([
{ $project: {
customerName: 1,
orderDate: 1,
expensiveItems: {
$filter: {
input: "$lineItems",
as: "item",
cond: { $gt: ["$$item.price", 50] }
}
}
}}
]);Document structure stays intact. Fewer elements in the array, nothing else changes.
Window Functions and $setWindowFields
Since MongoDB 5.0. The key difference from $group: window functions keep every document. They add computed fields without collapsing rows.
Running totals, moving averages, rankings. All without losing individual documents.
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $setWindowFields: {
partitionBy: "$customerId",
sortBy: { orderDate: 1 },
output: {
runningTotal: {
$sum: "$total",
window: {
documents: ["unbounded", "current"]
}
},
orderRank: {
$rank: {}
},
movingAvg: {
$avg: "$total",
window: {
documents: [-2, 0]
}
}
}
}}
]);partitionBy groups by customer. Within each partition, documents sort by date.
runningTotal sums from the first document to the current one. Cumulative total per customer. orderRank assigns a rank within each partition. movingAvg averages the current document and the two before it — a 3-order moving average from the [-2, 0] window.
Every original document survives. New fields just appear alongside the existing ones. And if you have been reaching for application code to compute running totals or rolling averages, stop. The database does this faster, and the logic stays where the data lives instead of scattered across your service layer.
Performance Optimization
A correct pipeline that takes 30 seconds is not finished. Works fine on test data, falls apart on production volumes. Every time.
Index Your First $match
The pipeline itself can only use an index for the stages at the start: the initial $match and a $sort right after it. Once documents have been reshaped, later stages work on what the earlier ones passed down. $lookup is different because it queries a second collection, which is why its foreignField needs an index of its own (below). If your first stage filters on { status: "completed", orderDate: { $gte: ... } }, you need a compound index on { status: 1, orderDate: 1 }. Verify with .explain("executionStats"). No index on the first $match means a full collection scan before the pipeline even starts.
Limit Early, Project Early
Do not push a million documents through $group and $lookup only to take the top 10 at the end. Move $limit up. Use $project early to drop fields. Fewer fields means less memory at every stage.
Know Where allowDiskUse Applies
Each blocking stage, such as $group, $sort without an index, $bucket or $setWindowFields, can use 100 MB of RAM. Since MongoDB 6.0 the server parameter allowDiskUseByDefault is true, so those stages write temporary files to disk instead of failing. On older servers, or where an administrator has turned that default off, pass the option yourself:
// Needed on MongoDB 5.x, or when allowDiskUseByDefault is false
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $group: {
_id: "$productCategory",
totalRevenue: { $sum: "$total" },
allCustomers: { $addToSet: "$customerId" }
}},
{ $sort: { totalRevenue: -1 } }
], { allowDiskUse: true });Spilling keeps the query alive, but disk is much slower than memory. If a pipeline spills regularly, filter or project earlier so the blocking stage sees fewer, smaller documents. $facet is the exception: it cannot spill at all.
Index the $lookup Foreign Field
$lookup on an unindexed foreign collection is a collection scan per document. Millions of documents, no index? Every single join does a full scan. Always index the foreignField. It is an easy one to miss, because the pipeline works fine on small test data.
Materialized Views with $merge
Same expensive pipeline running on every dashboard load? Write the results to a separate collection with $merge. Query that collection directly. Rerun the pipeline when source data changes. $out works too but replaces the entire output collection. $merge can upsert.
Profile First
.explain("executionStats"). Check executionTimeMillis and totalDocsExamined. If docs examined far exceeds docs returned, you need a better index or an earlier $match. Do not guess. The explain output tells you exactly which stages are expensive.
$facet: Multiple Pipelines in One Query
$facet sends the same input documents through several independent sub-pipelines and returns one document with a field for each. The documents are read once, before the stage, so a dashboard that needs three summaries can get them in a single round trip.
// One query for a dashboard: totals, top products and orders per region
db.orders.aggregate([
// Runs first and can use the { status: 1, orderDate: 1 } index
{ $match: {
status: "completed",
orderDate: { $gte: ISODate("2026-01-01") }
}},
{ $facet: {
summary: [
{ $group: { _id: null, revenue: { $sum: "$total" }, orders: { $sum: 1 } } },
{ $project: { _id: 0 } }
],
topProducts: [
{ $unwind: "$items" },
{ $group: { _id: "$items.productId", sold: { $sum: "$items.quantity" } } },
{ $sort: { sold: -1 } },
{ $limit: 5 }
],
byRegion: [
{ $sortByCount: "$shippingAddress.region" }
]
}}
]);
// Result: one document
// { summary: [{ revenue: ..., orders: ... }], topProducts: [...], byRegion: [...] }
You define named facets, each containing its own pipeline stages. One facet computes total revenue. Another computes top products. A third computes customer counts by region. All of them run against the same input documents, all in a single round trip to the database. The result is one document with one field per facet.
Most teams discover $facet after building three separate aggregation queries for a dashboard and realizing they could be one. If your API handler runs multiple aggregations against the same collection with overlapping $match conditions, $facet collapses them into a single query. Network round trips drop, and the shared $match runs once instead of three times.
Three limits to know before you rely on it:
- Indexes only before the stage. Sub-pipelines work on the documents passed into the stage, so they do not use indexes, and a
$facetthat is the first stage scans the whole collection. Put an indexed$match(and$sort, if you need one) in front of it. - No spilling to disk. Each sub-pipeline is held to 100 MB of memory, and
allowDiskUsedoes not change that. Keep the input small and use$limitinside facets that list documents. - One 16 MB result. The output is a single document, so it must fit in MongoDB's 16 MB document limit. Facets that return raw documents hit this quickly.
You also cannot use $out, $merge, $geoNear or another $facet inside a facet.