MongoDB Aggregation Pipeline: Stages, Operators & Real Examples
Master the MongoDB aggregation pipeline: $match, $group, $project, $sort, $lookup, $unwind, $facet, and $bucket with real-world analytics examples. Lesson 6 of MongoDB & NoSQL Mastery.

The aggregation pipeline is MongoDB's answer to SQL's GROUP BY, HAVING, JOIN, and window functions. You pass documents through a sequence of stages — each stage transforms the data and passes results to the next. Understanding the pipeline unlocks real-time analytics, reporting, and data transformation directly in the database, without pulling data into application memory.
Previous: Lesson 5 — Querying MongoDB
Pipeline Fundamentals
A pipeline is an array of stage documents passed to db.collection.aggregate():
db.orders.aggregate([
{ $match: { status: "shipped" } }, // Stage 1: filter
{ $group: { _id: "$customerId", total: { $sum: "$amount" } } }, // Stage 2: aggregate
{ $sort: { total: -1 } }, // Stage 3: sort
{ $limit: 10 } // Stage 4: top 10
])Stages execute in order. Each stage receives the output of the previous stage. $match and $limit early in the pipeline reduce the document set before expensive stages like $group and $lookup.
Core Stages
$match — Filter Documents
Identical to a find() query filter. Always place $match as early as possible:
// Only process orders from 2026
{ $match: { createdAt: { $gte: ISODate("2026-01-01"), $lt: ISODate("2027-01-01") } } }
// Combine multiple conditions
{ $match: { status: { $in: ["shipped", "delivered"] }, amount: { $gt: 0 } } }$group — Aggregate and Summarise
$group collapses multiple documents into one per unique _id value:
// Revenue per customer
{ $group: {
_id: "$customerId",
totalRevenue: { $sum: "$amount" },
orderCount: { $sum: 1 },
avgOrderValue: { $avg: "$amount" },
firstOrder: { $min: "$createdAt" },
lastOrder: { $max: "$createdAt" }
}}
// Group by multiple fields
{ $group: {
_id: { year: { $year: "$createdAt" }, month: { $month: "$createdAt" } },
revenue: { $sum: "$amount" }
}}
// Grand total (group all documents)
{ $group: { _id: null, total: { $sum: "$amount" } } }Accumulator operators: $sum, $avg, $min, $max, $first, $last, $push (collect into array), $addToSet (unique values).
$project — Reshape Documents
// Include only specific fields and add computed fields
{ $project: {
_id: 0,
customerId: 1,
amount: 1,
year: { $year: "$createdAt" },
month: { $month: "$createdAt" },
discountedAmount: { $multiply: ["$amount", 0.9] }
}}
// Rename a field
{ $project: { orderTotal: "$amount", _id: 0 } }
// Conditional value
{ $project: {
tier: {
$cond: { if: { $gte: ["$amount", 500] }, then: "premium", else: "standard" }
}
}}$sort, $limit, $skip
{ $sort: { totalRevenue: -1 } } // Descending
{ $limit: 5 }
{ $skip: 20 }$count — Count Pipeline Documents
// Count total matching documents
db.orders.aggregate([
{ $match: { status: "cancelled" } },
{ $count: "cancelledOrders" }
])
// → [{ cancelledOrders: 142 }]$unwind — Flatten Arrays
$unwind deconstructs an array field into one document per element:
// Order document
{ _id: 1, items: [{ product: "Keyboard", qty: 1 }, { product: "Mouse", qty: 2 }] }
// After $unwind: { items }
{ _id: 1, items: { product: "Keyboard", qty: 1 } }
{ _id: 1, items: { product: "Mouse", qty: 2 } }Combined with $group, this is how you calculate totals across line items:
db.orders.aggregate([
{ $unwind: "$items" },
{ $group: {
_id: "$items.product",
totalQtySold: { $sum: "$items.qty" },
revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } }
}},
{ $sort: { revenue: -1 } }
])$lookup — Join Collections
$lookup performs a left outer join with another collection:
// Join orders with customer details
db.orders.aggregate([
{ $lookup: {
from: "customers", // collection to join
localField: "customerId", // field in orders
foreignField: "_id", // field in customers
as: "customer" // output array field name
}},
{ $unwind: "$customer" }, // flatten the single-element array
{ $project: {
orderId: "$_id",
customerName: "$customer.name",
amount: 1
}}
])
$lookupis expensive: it cannot use indexes efficiently on the joined collection in all cases. For high-frequency queries, consider embedding data at write time rather than joining at read time.
Pipeline $lookup (MongoDB 3.6+)
For complex join conditions, use the pipeline form:
{ $lookup: {
from: "products",
let: { productIds: "$items.productId" },
pipeline: [
{ $match: { $expr: { $in: ["$_id", "$$productIds"] } } },
{ $project: { name: 1, price: 1 } }
],
as: "productDetails"
}}$addFields and $set
Add new fields or overwrite existing ones without re-specifying the whole document:
{ $addFields: {
fullName: { $concat: ["$firstName", " ", "$lastName"] },
ageGroup: {
$switch: {
branches: [
{ case: { $lt: ["$age", 18] }, then: "minor" },
{ case: { $lt: ["$age", 65] }, then: "adult" }
],
default: "senior"
}
}
}}$facet — Multi-Dimensional Analytics
$facet runs multiple sub-pipelines on the same input documents in a single pass:
db.products.aggregate([
{ $match: { status: "active" } },
{ $facet: {
byBrand: [
{ $group: { _id: "$brand", count: { $sum: 1 } } },
{ $sort: { count: -1 } }
],
priceDistribution: [
{ $bucket: {
groupBy: "$price",
boundaries: [0, 25, 50, 100, 200, 500],
default: "500+",
output: { count: { $sum: 1 }, avgPrice: { $avg: "$price" } }
}}
],
total: [{ $count: "count" }]
}}
])$bucket and $bucketAuto — Histogram Data
// Fixed price buckets
{ $bucket: {
groupBy: "$price",
boundaries: [0, 50, 100, 200, 500],
default: "500+",
output: { count: { $sum: 1 }, products: { $push: "$name" } }
}}
// Automatic equal-sized buckets
{ $bucketAuto: {
groupBy: "$price",
buckets: 5,
output: { count: { $sum: 1 } }
}}Real-World Pipeline: Monthly Revenue Report
db.orders.aggregate([
// 1. Only confirmed orders from 2026
{ $match: {
status: { $in: ["shipped", "delivered"] },
createdAt: { $gte: ISODate("2026-01-01") }
}},
// 2. Group by year-month
{ $group: {
_id: {
year: { $year: "$createdAt" },
month: { $month: "$createdAt" }
},
revenue: { $sum: "$amount" },
orders: { $sum: 1 },
avgOrder: { $avg: "$amount" }
}},
// 3. Sort chronologically
{ $sort: { "_id.year": 1, "_id.month": 1 } },
// 4. Format the output
{ $project: {
_id: 0,
period: { $concat: [
{ $toString: "$_id.year" }, "-",
{ $toString: "$_id.month" }
]},
revenue: { $round: ["$revenue", 2] },
orders: 1,
avgOrder: { $round: ["$avgOrder", 2] }
}}
])