Picture this: you're building an analytics dashboard. You need the top 10 products by revenue for last month, grouped by category, with each product's percentage contribution to its category total, filtered to only show categories with more than $10,000 in sales.
Most developers write this as three to four separate queries, pull the data into application memory, run JavaScript array operations to join and compute the percentages, then send the result to the frontend. It works. It's also slow, expensive to maintain, and puts load on your app server that should be sitting in your database.
MongoDB's aggregation pipeline does all of this in a single query. And if you haven't internalized the pipeline model yet, you're leaving a significant amount of MongoDB's capability on the table.
This isn't just about writing cleaner code. At scale, pushing computation to the database — where the data lives, where indexes can be used, where the query planner can optimize — is the difference between a dashboard that loads in 200ms and one that times out under load.
Why Aggregation Pipelines Exist
MongoDB's basic find() is a filter: you describe which documents you want, and MongoDB returns them. That's all it does. For simple lookups and CRUD operations, it's all you need.
The moment you need to transform, group, calculate, or join data — you've outgrown find(). Before aggregation pipelines, developers had two options: MapReduce (MongoDB's older, slower, JavaScript-based computation model) or pulling data into application code and doing it there.
Both are bad. MapReduce is slow and JavaScript-heavy. Application-side processing means transferring potentially millions of documents over the network before you can filter or aggregate them.
The aggregation pipeline solves this by bringing a SQL-like computation model to MongoDB, but designed for documents rather than tables. Data flows through a sequence of stages. Each stage transforms the data and passes the result to the next. The database does the heavy lifting. Your application receives exactly the shaped result it needs.
The analogy that makes this click: an aggregation pipeline is a Unix pipe for your data.
# Unix: data flows left to right through transformations
cat access.log | grep "POST" | awk '{print $7}' | sort | uniq -c | sort -rn | head -10
# MongoDB aggregation: data flows through stages
db.orders.aggregate([
{ $match: { method: "POST" } }, // grep
{ $group: { _id: "$endpoint" } }, // awk + uniq
{ $sort: { count: -1 } }, // sort -rn
{ $limit: 10 } // head -10
])Same mental model. Each stage receives a stream of documents, does one thing to them, and passes the result forward. Compose stages to build arbitrarily complex transformations.
The Stages That Matter Most
MongoDB has over 30 aggregation stages. You'll use about 8 of them for 90% of real-world queries. Here they are, with emphasis on the non-obvious parts.
$match — Filter Early and Often
{ $match: { status: "active", createdAt: { $gte: ISODate("2025-01-01") } } }$match is MongoDB's filter. It's identical in syntax to a find() query. The critical rule: put $match as early as possible in your pipeline.
Why this matters for performance: $match is the only stage that can use indexes. Once documents pass through any other stage, they're in-memory transformed documents — indexes no longer apply. A $match at the beginning of your pipeline uses your indexes and dramatically reduces the number of documents that need to flow through subsequent stages.
The common mistake: putting $match after $lookup or $group. You've now joined or aggregated your entire collection before filtering, which is enormously more expensive than filtering first.
The rule: filter aggressively with $match before you compute anything.
$group — The Engine of Aggregation
$group is where aggregation earns its name. It collapses multiple documents into fewer documents by grouping on a field, and computes aggregate values for each group.
{
$group: {
_id: "$category", // group by this field
totalRevenue: { $sum: "$price" }, // sum prices within each group
orderCount: { $sum: 1 }, // count documents in each group
avgOrderValue: { $avg: "$price" }, // average price per group
uniqueCustomers: { $addToSet: "$userId" }, // collect unique values
firstOrder: { $min: "$createdAt" }, // earliest date in group
lastOrder: { $max: "$createdAt" } // latest date in group
}
}The _id field in $group is the grouping key — the field (or expression) you're grouping by. Set it to null to aggregate the entire collection into a single document:
// Get totals across the entire collection
{ $group: { _id: null, totalRevenue: { $sum: "$price" }, docCount: { $sum: 1 } } }Multi-field grouping — group by a combination of fields using an object as _id:
// Group by both category AND month
{
$group: {
_id: {
category: "$category",
month: { $month: "$createdAt" }
},
revenue: { $sum: "$price" }
}
}$group is the stage developers most often reach for MapReduce to do — and it handles every standard aggregation pattern without JavaScript.
$project — Reshape the Document
$project controls what fields appear in the output and lets you compute new fields.
{
$project: {
// Include fields (1 = include, 0 = exclude)
productName: 1,
category: 1,
_id: 0, // explicitly exclude _id
// Compute new fields
totalValue: { $multiply: ["$price", "$quantity"] },
// String operations
displayName: { $toUpper: "$productName" },
// Conditional fields
discountLabel: {
$cond: {
if: { $gte: ["$discount", 0.2] },
then: "BIG SALE",
else: "Regular price"
}
},
// Date extraction
year: { $year: "$createdAt" },
month: { $month: "$createdAt" }
}
}The important distinction between $project and $addFields:
$projectreshapes the document — you control exactly which fields exist in the output. Unspecified fields are dropped.$addFieldsadds to the document — you specify new or modified fields, and all existing fields are preserved. Use$projectwhen you want to slim down a document before passing it to the next stage (reduces memory usage for large documents). Use$addFieldswhen you want to add computed fields without losing the existing ones.
$lookup — The Join That MongoDB Didn't Used to Have
$lookup performs a left outer join with another collection. It's MongoDB's answer to SQL JOINs.
// Basic lookup: join orders with their customer records
{
$lookup: {
from: "customers", // the collection to join with
localField: "customerId", // field in the current collection
foreignField: "_id", // field in the foreign collection
as: "customerInfo" // output field name (will be an array)
}
}The result adds a customerInfo array to each order document containing the matching customer records. Since it's always an array, you often immediately follow with $unwind to flatten it:
{ $unwind: { path: "$customerInfo", preserveNullAndEmpty: true } }The pipeline form of $lookup — more powerful for complex joins:
{
$lookup: {
from: "products",
let: { orderedProductIds: "$productIds", minQty: "$minQuantity" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $in: ["$$id", "$$orderedProductIds"] },
{ $gte: ["$stock", "$$minQty"] }
]
}
}
},
{ $project: { name: 1, price: 1, stock: 1 } }
],
as: "availableProducts"
}
}The pipeline form lets you filter and shape the joined documents within the lookup itself — rather than joining everything and filtering afterward. For large collections, this is significantly more efficient.
Performance warning on $lookup: joins are expensive. MongoDB must perform the join in-memory. For large collections, always ensure the foreignField has an index. An unindexed $lookup on a million-document collection will be extremely slow.
$unwind — Flatten Arrays to Work With Them
When a document contains an array field, $unwind creates one document per array element. This is usually a prerequisite for grouping or operating on array contents.
// Before $unwind:
{ _id: 1, product: "Laptop", tags: ["electronics", "portable", "work"] }
// After { $unwind: "$tags" }:
{ _id: 1, product: "Laptop", tags: "electronics" }
{ _id: 1, product: "Laptop", tags: "portable" }
{ _id: 1, product: "Laptop", tags: "work" }Common pattern: unwind, group by the array element, then count or aggregate:
// Count how many products have each tag
[
{ $unwind: "$tags" },
{ $group: { _id: "$tags", count: { $sum: 1 } } },
{ $sort: { count: -1 } }
]The preserveNullAndEmpty option matters in production:
{ $unwind: { path: "$tags", preserveNullAndEmpty: true } }Without this, documents with missing or empty tags arrays are silently dropped from the pipeline. This is a common source of mysterious missing records in aggregation results.
$sort, $limit, $skip — Pagination Done Right
{ $sort: { revenue: -1, createdAt: 1 } } // -1 descending, 1 ascending
{ $limit: 20 } // take first 20
{ $skip: 40 } // skip first 40 (page 3 of 20)The performance rule for pagination: $sort + $limit is efficient when $sort uses an index. $sort on a non-indexed field means loading all documents into memory to sort them. For large collections, always have an index on fields you sort by frequently in aggregations.
$skip + $limit for deep pagination is inefficient at scale. Skipping 10,000 documents still requires loading and discarding 10,000 documents. For high-volume pagination, use range-based pagination instead:
// Instead of: { $skip: 10000 }, { $limit: 20 }
// Use: { $match: { _id: { $gt: lastSeenId } } }, { $limit: 20 }$addFields — Computed Fields Without Losing Data
{
$addFields: {
// Revenue = price × quantity
revenue: { $multiply: ["$price", "$quantity"] },
// Profit margin as percentage
marginPct: {
$multiply: [
{ $divide: [{ $subtract: ["$price", "$cost"] }, "$price"] },
100
]
},
// Age of document in days
ageInDays: {
$divide: [
{ $subtract: [new Date(), "$createdAt"] },
1000 * 60 * 60 * 24
]
}
}
}$addFields is particularly useful for computing intermediate values that subsequent stages will filter or group on. Add your computed fields early in the pipeline, then $match or $group on them later.
Building a Real Pipeline: The Analytics Dashboard Query
Let's build the query from the introduction — top products by revenue, grouped by category, with percentage contribution, filtered by minimum category revenue.
The data model:
// orders collection
{
"_id": ObjectId("..."),
"productId": "laptop-pro-15",
"productName": "Laptop Pro 15",
"category": "electronics",
"price": 1299.99,
"quantity": 2,
"customerId": "user_123",
"status": "completed",
"createdAt": ISODate("2025-05-15T10:30:00Z")
}Building it stage by stage:
db.orders.aggregate([
// Stage 1: Filter to completed orders in the last 30 days
// Do this first — reduces the document set before any computation
{
$match: {
status: "completed",
createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
}
},
// Stage 2: Compute revenue per line item
// Price × quantity, so $group can sum it correctly
{
$addFields: {
lineRevenue: { $multiply: ["$price", "$quantity"] }
}
},
// Stage 3: Group by product to get per-product totals
{
$group: {
_id: {
productId: "$productId",
productName: "$productName",
category: "$category"
},
productRevenue: { $sum: "$lineRevenue" },
unitsSold: { $sum: "$quantity" },
orderCount: { $sum: 1 }
}
},
// Stage 4: Group by category to get category totals
// Preserve the product-level data as an array
{
$group: {
_id: "$_id.category",
categoryRevenue: { $sum: "$productRevenue" },
products: {
$push: {
productId: "$_id.productId",
productName: "$_id.productName",
revenue: "$productRevenue",
unitsSold: "$unitsSold"
}
}
}
},
// Stage 5: Filter out categories below the revenue threshold
{
$match: { categoryRevenue: { $gte: 10000 } }
},
// Stage 6: Add percentage contribution for each product within its category
{
$addFields: {
products: {
$map: {
input: "$products",
as: "product",
in: {
productId: "$$product.productId",
productName: "$$product.productName",
revenue: "$$product.revenue",
unitsSold: "$$product.unitsSold",
pctOfCategory: {
$round: [
{ $multiply: [
{ $divide: ["$$product.revenue", "$categoryRevenue"] },
100
]},
1 // round to 1 decimal place
]
}
}
}
}
}
},
// Stage 7: Sort products within each category by revenue
{
$addFields: {
products: {
$slice: [
{ $sortArray: { input: "$products", sortBy: { revenue: -1 } } },
10 // top 10 products per category
]
}
}
},
// Stage 8: Sort categories by total revenue
{ $sort: { categoryRevenue: -1 } }
])Sample output:
[
{
"_id": "electronics",
"categoryRevenue": 284750.00,
"products": [
{
"productId": "laptop-pro-15",
"productName": "Laptop Pro 15",
"revenue": 89400.00,
"unitsSold": 68,
"pctOfCategory": 31.4
},
{
"productId": "wireless-headphones",
"productName": "Wireless Headphones",
"revenue": 44200.00,
"unitsSold": 221,
"pctOfCategory": 15.5
}
]
}
]Compare this to the alternative: multiple find() queries, joining in application code, computing percentages in JavaScript, then sorting and slicing in memory. The pipeline version does all of it in the database, returns exactly the shape the frontend needs, and can be optimized with indexes that your application-side code can't use.
Performance Rules That Actually Matter
Rule 1: $match before $group before $lookup.
The order you put stages in has a massive performance impact. Filter first (reduces documents), aggregate second (reduces to fewer grouped documents), join last (join as few documents as possible).
// Slow: join everything, then filter
[{ $lookup: {...} }, { $match: { status: "active" } }]
// Fast: filter first, then join only matching documents
[{ $match: { status: "active" } }, { $lookup: {...} }]Rule 2: Index fields you $match and $sort on.
For an aggregation pipeline, MongoDB can only use an index for the first $match and the first $sort stage (when they appear early in the pipeline). Everything after that is in-memory. Make those stages count.
Check what your query planner is doing with explain():
db.orders.explain("executionStats").aggregate([
{ $match: { status: "completed" } },
{ $group: { _id: "$category", total: { $sum: "$price" } } }
])
// Look for "IXSCAN" (index scan) vs "COLLSCAN" (collection scan)
// COLLSCAN on large collections = slowRule 3: $project or $addFields early to slim large documents.
If your documents are large (many fields, nested objects) but you only need a few fields for the aggregation, project down to just those fields early. Smaller documents flowing through subsequent stages means less memory usage and faster processing.
// For a large document with 30 fields, if you only need 4:
{ $project: { category: 1, price: 1, quantity: 1, createdAt: 1 } }
// Put this immediately after your first $matchRule 4: allowDiskUse for large aggregations.
By default, MongoDB's aggregation pipeline stages are limited to 100MB of RAM. For large datasets, add allowDiskUse: true to spill to disk rather than fail:
db.orders.aggregate([...], { allowDiskUse: true })This is slower than in-memory processing but better than a failed query. Use it as a safety net; optimize the pipeline to reduce memory usage as a follow-up.
Real-World Scenarios
User activity reports: "Show me daily active users for the last 30 days, broken down by platform." $match on date range → $group by { day: { $dateToString: ... }, platform: "$platform" } → $sort by date. One pipeline, no joins needed.
Inventory management: "Which products have sold more than 50% of their initial stock this month?" $match on date → $group by product → $lookup to join inventory collection → $addFields to compute sell-through percentage → $match where percentage > 50.
Content recommendations: "For this user, find the top 5 categories by purchase volume, then get the 3 newest products in each category." Two pipelines or a well-structured single pipeline with $facet (which runs multiple sub-pipelines on the same input in parallel).
Fraud detection signals: "Flag orders where the same user placed more than 5 orders in a single hour." $match on recent time window → $group by { userId, hour } → $match where count > 5. Runs in the database, returns only the suspicious patterns.
Decision Framework
Reach for aggregation pipelines when:
- ✔ You're making multiple queries and joining results in application code
- ✔ You're filtering documents after loading them (doing what
$matchshould do) - ✔ You need grouped summaries, counts, or averages
- ✔ You need to transform document shape before sending to the frontend
- ✔ You're working with array fields that need to be flattened, filtered, or summarized
Stick with
find()when: - ✔ You need a specific document by ID or simple field match
- ✔ You need to retrieve a list of documents without transformation
- ✔ The query is simple enough that aggregation overhead isn't justified
- ✔ You need real-time single-document lookups on hot paths
The rule of thumb: if your application code touches a MongoDB result and transforms it before using it, that transformation probably belongs in the pipeline.
Key Insights
MongoDB's query optimizer can reorder your stages — to a point. The query planner will automatically move a $match before a $sort if doing so is safe and beneficial. But it can't move stages across complex transformations like $group or $lookup. Don't rely on the optimizer for dramatic reordering — write the pipeline in the efficient order yourself.
$facet is the stage most developers discover too late. $facet runs multiple independent aggregation sub-pipelines on the same input documents, returning all results in a single query. This is what powers "count + paginated results in one query" — a common need that most developers solve with two separate queries.
db.products.aggregate([
{ $match: { category: "electronics" } },
{
$facet: {
results: [{ $sort: { price: -1 } }, { $limit: 20 }],
totalCount: [{ $count: "count" }],
priceStats: [{ $group: { _id: null, avg: { $avg: "$price" }, max: { $max: "$price" } } }]
}
}
])One round-trip to the database, three different aggregation results.
The pipeline is the documentation. Well-written aggregation pipelines are more self-documenting than equivalent application code. Each stage has one responsibility, the data flow is explicit, and the shape transformation is visible. Add comments to complex stages — MongoDB ignores them but your teammates won't.
Aggregation pipelines can be exported as views. If you have a pipeline you run frequently, you can create a MongoDB view from it — a read-only collection that's the materialized output of the pipeline on query. Complex analytical queries become simple find() calls against the view.
db.createView("monthlyRevenue", "orders", [
{ $match: { status: "completed" } },
{ $group: { _id: { $month: "$createdAt" }, total: { $sum: "$price" } } }
])
// Now it's just:
db.monthlyRevenue.find()Common Mistakes
Not using $match to filter before $lookup. Joining a 5M-document collection with another 5M-document collection is catastrophically slow. Filter to the relevant subset first, then join only those documents.
Forgetting preserveNullAndEmpty on $unwind. Documents with null or empty arrays silently disappear from your pipeline output. If your result counts seem off, this is often why. Default behavior drops these documents — add the option to preserve them.
Computing $project after $group when you could compute during $group. Many field computations can happen inside the $group accumulator expressions rather than in a separate $project stage. One fewer stage means slightly faster execution.
Using $where or JavaScript-based operators inside pipelines. MongoDB supports $function and $accumulator for custom JavaScript in aggregations. They're tempting and extremely slow — JavaScript execution in the query engine is much slower than native aggregation operators. If you need custom logic, check if there's a native operator first.
Not running explain() on slow pipelines. Aggregation pipelines have their own explain output showing stage-by-stage execution stats. Looking at executionTimeMillisEstimate per stage immediately shows you which stage is the bottleneck.
TL;DR
- Aggregation pipelines are Unix pipes for MongoDB data — each stage does one thing, passes results to the next
$match→ filter early, uses indexes, do this first and often$group→ collapse documents by a key, compute sums/counts/averages per group$project/$addFields→ reshape documents; project drops unspecified fields, addFields preserves them$lookup→ left outer join with another collection; always index the foreign field$unwind→ flatten arrays into separate documents; usepreserveNullAndEmptyto avoid silent data loss$sort+$limit→ sort uses indexes when placed early; skip/limit pagination is inefficient at depth$facet→ run multiple sub-pipelines in one query; use for count + results in one round-trip- Performance order:
$match→$addFields→$group→$lookup→$project→$sort - If your app code transforms MongoDB results before use, that transformation belongs in the pipeline
Conclusion
The developers who get the most out of MongoDB aren't the ones who use it as a fancy JSON file system — running simple find() queries and moving data into application code for everything interesting. They're the ones who've internalized the pipeline model and pushed computation down to where the data lives.
The aggregation pipeline isn't just a query language. It's the difference between your database being a data store and being a data processing engine. Between shipping five database round-trips per page load and shipping one. Between an analytics dashboard that scales to millions of records and one that falls over under production load.
The pipeline model takes an hour to learn and pays dividends every time you reach for it instead of application-side processing. The index and ordering rules are what separate a pipeline that runs in 80ms from one that runs in 8 seconds on the same data.
Learn the model. Watch your query counts drop.
What Are You Still Doing in Application Code?
What data transformation or aggregation are you currently doing in your application after a MongoDB query — filtering, joining, grouping, computing percentages — that you've never tried pushing into a pipeline?
There's a good chance it belongs there. Drop it in the comments and let's figure out the pipeline together.



