This article is published in English.
Building MongoDB Aggregation Pipelines with $match, $group and $lookup
Learn how MongoDB aggregation stages filter, group, reshape, join and sort documents, and how to chain them into a pipeline that answers real reporting questions.
A plain find() query is fine for fetching documents that match a condition, but it cannot total revenue per customer, attach user details to orders or return a ranked report. For that, MongoDB offers the aggregation framework. This guide explains the five stages you will use most often, the small traps in each, and how to combine them into one pipeline that produces a clean, ready-to-use result.
How an aggregation pipeline works
An aggregation is an ordered list of stages. Documents enter the first stage, each stage transforms the stream in one specific way, and whatever it emits becomes the input of the next stage. Thinking of it as a data assembly line makes the order of stages easy to reason about.
The short pipeline below keeps only completed orders, sums the amount for each customer and lists the biggest spenders first.
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $group: { _id: "$customerId", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } }
]);
The following sections look at each stage on its own.
Filtering documents with $match
$match accepts the same kind of filter you would pass to find(). In this example, only documents whose status equals "completed" pass through to the next stage.
{
$match: {
status: "completed"
}
}
Place $match as early as possible. Every document removed at the start is one fewer document the later stages must process, and a $match at the beginning of a pipeline can use indexes on the collection, while later stages operate on intermediate results that indexes cannot help with.
Grouping and computing totals with $group
$group collects documents that share a key and computes values over each group. The example produces one document per customer with the combined order amount.
{
$group: {
_id: "$customerId",
totalSales: {
$sum: "$amount"
}
}
}
Two parts do the work:
_iddefines the grouping key; here it is the value of each document'scustomerIdfield (the$prefix means "read this field").$sumis an accumulator that adds upamountfor every document in the group.
Other accumulators follow the same pattern, including $avg, $min, $max and $count. Keep in mind that the output of $group contains only _id and the fields you computed; every other field of the original documents is gone.
Selecting and reshaping fields with $project
$project decides which fields appear in the output and can create new ones. Setting a field to 1 includes it, and _id: 0 explicitly hides the identifier, which is otherwise included by default.
{
$project: {
customerId: 1,
totalSales: 1,
_id: 0
}
}
It can also compute values. Here $multiply produces a totalWithTax field by applying an 18% tax factor to totalSales.
{
$project: {
customerId: 1,
totalWithTax: {
$multiply: ["$totalSales", 1.18]
}
}
}
One detail matters when these snippets follow a $group stage: the customer identifier then lives in _id, not in customerId, so customerId: 1 would output nothing. The combined pipeline at the end handles this by writing customerId: "$_id", which renames the field. Treat $project as the stage that shapes the final response your API returns.
Joining another collection with $lookup
Documents often reference data stored elsewhere. With an orders collection and a users collection, $lookup pulls in the matching user for each order.
{
$lookup: {
from: "users",
localField: "customerId",
foreignField: "_id",
as: "customer"
}
}
For every order, MongoDB compares the order's customerId with _id in users and writes all matches into a new array field named customer. This is the closest equivalent to a SQL join. The result is always an array, even when exactly one user matches, so it is common to follow the lookup with $unwind or to read the first element. Also make sure both fields share a type: a string customerId will not match an ObjectId in _id.
Ordering results with $sort
$sort orders the documents by one or more fields. A value of 1 sorts ascending and -1 descending, so the example ranks customers from the highest totalSales to the lowest.
{
$sort: {
totalSales: -1
}
}
Combining the stages into one report
The real value appears when stages are chained. The pipeline below turns raw orders into a sorted list of customers and their total sales:
db.orders.aggregate([
{
$match: {
status: "completed"
}
},
{
$group: {
_id: "$customerId",
totalSales: {
$sum: "$amount"
}
}
},
{
$sort: {
totalSales: -1
}
},
{
$project: {
customerId: "$_id",
totalSales: 1,
_id: 0
}
}
]);
Step by step, it:
- Keeps only completed orders with
$match. - Groups them per customer with
$group. - Sums each customer's order amounts into
totalSales. - Sorts customers by that total, highest first.
- Uses
$projectto rename_idtocustomerIdand drop_idfrom the response.
To include names or emails, you could add a $lookup against users. Placing it after $group, and after any $limit, means the join runs once per customer instead of once per order.
Key takeaways
A compact way to remember the core stages:
$matchfilters.$groupgroups and calculates.$projectselects and reshapes.$lookupjoins collections.$sortorders results.
Together they cover much of what reports, dashboards, analytics endpoints and business rules need once you move past basic CRUD. Order matters: filter early, group once, join as late as the data allows and shape the output last. In interviews and design reviews, being able to justify why a stage sits where it does is more convincing than reciting what each one does.