MongoDB — Aggregation Pipeline
A multi-stage data-processing language. Each stage takes documents in and emits documents out. The replacement for SQL’s GROUP BY / JOIN / window functions / CTEs — different syntax, similar power.
The pipeline shape
db.orders.aggregate([
{"$match": {"status": "completed", "created_at": {"$gte": cutoff}}},
{"$lookup": {"from": "users", "localField": "user_id", "foreignField": "_id", "as": "user"}},
{"$unwind": "$user"},
{"$group": {"_id": "$user.country", "total": {"$sum": "$amount"}, "count": {"$sum": 1}}},
{"$sort": {"total": -1}},
{"$limit": 10},
])
Read like SQL’s left-to-right: filter → join → flatten → aggregate → sort → limit.
Core stages
$match — filter
{"$match": {"status": "pending", "total": {"$gt": 100}}}
Same query syntax as .find(). Put $match as early as possible — it reduces work for downstream stages and can use indexes (if it’s first in the pipeline).
$project — shape output
{"$project": {
"_id": 0,
"order_id": "$_id",
"year": {"$year": "$created_at"},
"total_with_tax": {"$multiply": ["$total", 1.07]},
}}
Drops fields not mentioned, computes new ones with expressions. Like SQL SELECT.
$group — aggregate
{"$group": {
"_id": {"country": "$user.country", "month": {"$month": "$created_at"}},
"total": {"$sum": "$amount"},
"avg": {"$avg": "$amount"},
"count": {"$sum": 1},
"max_order": {"$max": "$amount"},
"items": {"$push": "$items"}, # collect all values into an array
}}
The _id is the grouping key (single field, document, or expression). Accumulators: $sum, $avg, $min, $max, $first, $last, $push, $addToSet.
$sort and $limit
{"$sort": {"total": -1}},
{"$limit": 100},
$sort in memory is limited to 100 MB by default (raise with allowDiskUse=True if you need bigger sorts).
$lookup — left outer join
{"$lookup": {
"from": "users",
"localField": "user_id",
"foreignField": "_id",
"as": "user",
}}
Joins orders.user_id = users._id, embeds the matching users doc(s) as user array. Always an array — even single matches. Use $unwind to flatten.
Performance: $lookup is expensive; it scans the foreign collection per input doc unless the foreign side is indexed. Always index foreignField.
For “JOIN then aggregate,” there’s also a pipeline-form $lookup that pre-filters the foreign side:
{"$lookup": {
"from": "orders",
"let": {"uid": "$_id"},
"pipeline": [
{"$match": {"$expr": {"$eq": ["$user_id", "$$uid"]}, "status": "completed"}},
{"$project": {"total": 1}},
],
"as": "completed_orders",
}}
Reduces the join’s output before it’s attached.
$unwind — flatten arrays
{"$unwind": "$items"}
A doc with items: [a, b, c] becomes 3 docs, each with items: a, items: b, items: c. Standard before grouping by array elements.
Options: preserveNullAndEmptyArrays: true keeps docs with empty/missing arrays (analogous to LEFT JOIN).
$facet — multiple pipelines, one pass
{"$facet": {
"by_country": [
{"$group": {"_id": "$country", "total": {"$sum": "$amount"}}}
],
"by_month": [
{"$group": {"_id": {"$month": "$created_at"}, "total": {"$sum": "$amount"}}}
],
"top_users": [
{"$group": {"_id": "$user_id", "total": {"$sum": "$amount"}}},
{"$sort": {"total": -1}},
{"$limit": 10}
],
}}
Run multiple aggregations on the same input set, returning all results in one document. Saves N queries.
$addFields / $set
{"$addFields": {"total_with_tax": {"$multiply": ["$total", 1.07]}}}
Adds computed fields without dropping others (vs $project which is exclusive). $set is an alias.
$replaceRoot — promote a sub-doc
{"$replaceRoot": {"newRoot": "$user"}}
Replaces the document with the value at $user. Common after $lookup + $unwind when you want the joined doc to be the result.
$bucket, $bucketAuto — histograms
{"$bucket": {
"groupBy": "$age",
"boundaries": [0, 18, 30, 45, 60, 100],
"default": "other",
"output": {"count": {"$sum": 1}},
}}
Histogram-style aggregation. $bucketAuto picks boundaries automatically.
$out / $merge — write results
{"$out": "monthly_stats"} # replaces target collection
{"$merge": {"into": "monthly_stats", "whenMatched": "merge"}} # upsert per doc
Persists pipeline output as a collection. Useful for pre-computed views / reports.
Performance tips
$matchfirst. Filters before joins, projects, and groups. Hits indexes when first in the pipeline.$projectearly to reduce doc size. Less data per stage.- Index the join key on the foreign side.
$lookupwithout an index = full scan per input doc. - Avoid
$unwindof huge arrays. Doc count explodes. - Don’t sort before grouping if you can avoid it.
$groupdoesn’t need sorted input. allowDiskUse=Truefor big sorts / groups exceeding 100 MB.- Use
explain("executionStats")to verify stage costs.
Common patterns
Top-N per group
“Top 3 most-expensive orders per user”:
[
{"$sort": {"user_id": 1, "total": -1}},
{"$group": {
"_id": "$user_id",
"top_orders": {"$push": "$$ROOT"},
}},
{"$project": {
"top_orders": {"$slice": ["$top_orders", 3]}
}},
]
$$ROOT is the entire document. $slice takes the first 3.
Running totals
[
{"$sort": {"date": 1}},
{"$setWindowFields": {
"partitionBy": "$user_id",
"sortBy": {"date": 1},
"output": {
"running_total": {
"$sum": "$amount",
"window": {"documents": ["unbounded", "current"]}
}
}
}},
]
$setWindowFields (5.0+) — window functions, like SQL.
Time-series rollup
[
{"$match": {"timestamp": {"$gte": last_hour}}},
{"$group": {
"_id": {
"device": "$device_id",
"minute": {"$dateTrunc": {"date": "$timestamp", "unit": "minute"}}
},
"avg": {"$avg": "$value"},
"min": {"$min": "$value"},
"max": {"$max": "$value"},
}},
{"$out": "device_metrics_per_minute"},
]
Bucket time-series into per-minute aggregates. Run on a schedule.
Aggregation vs map-reduce
MongoDB has both. Map-reduce is legacy — slower, less expressive, deprecated in newer versions. Use aggregation pipeline.
Aggregation vs MongoDB views
db.create_collection(
"high_value_orders",
viewOn="orders",
pipeline=[{"$match": {"total": {"$gte": 100}}}],
)
A view is a saved pipeline. Read-only, evaluated at query time. Useful for sharing pre-filtered access patterns. Not materialized (unless you use $out/$merge).
Async with Motor
async def top_users():
cursor = db.orders.aggregate([
{"$group": {"_id": "$user_id", "total": {"$sum": "$amount"}}},
{"$sort": {"total": -1}},
{"$limit": 10},
])
return [doc async for doc in cursor]
Aggregation returns a cursor; iterate with async for.
Interview angle
- “What’s the aggregation pipeline?” — multi-stage data processing language. Each stage transforms documents (
$match,$group,$lookup,$project, etc.). Replaces SQL’s GROUP BY / JOIN / window functions with composable stages. - “How does
$lookupwork?” — left outer join.localFieldfrom input matched againstforeignFieldin target collection; matches embedded as array inasfield. Always an array (even single match) — use$unwindto flatten. - “Performance tips for a slow aggregation?” —
$matchand$projectearly; index$lookupforeign field; avoid$unwindon huge arrays;allowDiskUse=Truefor big sorts; useexplainto find bottlenecks. - “How do you compute top-N per group?” — sort by group + metric, then
$groupwith$push+$slice. Or in 5.0+, use$setWindowFieldswith$rank. - “
$facetuse case?” — run multiple aggregations on the same input in one pass. Dashboard backends: counts by country, counts by month, top users — all from one collection scan. - “How do you persist aggregation output?” —
$out(replaces target collection) or$merge(upsert per doc). Use for materialized views / pre-computed reports refreshed on a schedule. - “Aggregation vs MongoDB views?” — view is a saved pipeline, evaluated at query time, not materialized. Pipeline is the actual computation. Views encapsulate; pipelines transform.