AWS Builder Center

MongoDB Aggregation Pipelines: Why They Feel Complicated at First

Aggregation pipelines can look intimidating when you first encounter them. This article breaks down the concept stage by stage and shows how following the flow of data can make MongoDB aggregation much easier to understand.

Exploring Web Development and Cloud Computing

Why Did “Pipeline” Sound So Complicated?

When I first started learning MongoDB, I was intimidated by the term “aggregation pipeline.”
I remember wondering what a pipeline actually meant in the context of a database.
Then I started seeing operators such as:
1
2
3
4
5
$match
$group
$sort
$project
$lookup
all being used together.
At first, the entire thing looked like one complicated MongoDB operation. I found myself trying to understand the whole pipeline at once, which only made it more confusing.
But after spending some time with it, I realized that the aggregation pipeline itself wasn't as complicated as it initially appeared.
The real problem was the way I was looking at it.
Instead of trying to understand the entire pipeline as one piece of code, I started looking at what each stage was doing individually and, more importantly, what happened to the data after each stage.
That changed the way I understood aggregation.

What Does “Pipeline” Actually Mean?

Let's start with the simplest possible idea.
An aggregation pipeline is a sequence of stages.
The output from one stage becomes the input for the next stage.
You can visualize it like this:
1
2
3
4
5
6
7
8
9
Input Data
↓
Stage 1
↓
Stage 2
↓
Stage 3
↓
Final Result
That's essentially what a pipeline is.
Each stage performs a specific operation on the documents passing through it.
For example:
1
2
3
4
5
6
7
8
9
10
11
Data
↓
Filter
↓
Calculate
↓
Sort
↓
Reshape
↓
Result
So instead of thinking:
"This is one huge MongoDB query."
Try thinking:
"This is a series of small operations happening one after another."
That small change in perspective makes aggregation much easier to reason about.

Think of Every Stage as a Worker

One analogy that helped me understand aggregation was to think of each stage as a worker in a production line.
Imagine that you have a factory.
The first worker has one job.
The second worker has another.
The third worker does something different.
The product moves from one worker to the next until the final product is ready.
An aggregation pipeline works in a similar way.
1
2
3
4
5
6
7
8
9
10
11
12
13
Employees
↓
Worker 1 → Bring related department data
↓
Worker 2 → Process the department information
↓
Worker 3 → Filter employees
↓
Worker 4 → Calculate averages
↓
Worker 5 → Arrange the results
↓
Worker 6 → Format the final output
In MongoDB, those workers can be represented by stages such as:
StageWhat it does
$lookupBrings related data from another collection
$unwindDeconstructs an array into individual documents
$matchFilters documents
$groupCombines documents and performs calculations
$sortArranges documents
$projectSelects or reshapes fields
Once you start looking at aggregation this way, the operators become much less mysterious.

Let's Follow One Real Pipeline

Now let's look at an actual example.
Suppose we have two collections.

employees

1
2
3
4
5
6
7
[
{ name: "Rahul", departmentId: 101, salary: 60000 },
{ name: "Priya", departmentId: 102, salary: 50000 },
{ name: "Aman", departmentId: 101, salary: 80000 },
{ name: "Neha", departmentId: 103, salary: 90000 },
{ name: "Riya", departmentId: 101, salary: 70000 }
]
And a departments collection:
1
2
3
4
5
[
{ _id: 101, departmentName: "IT" },
{ _id: 102, departmentName: "HR" },
{ _id: 103, departmentName: "Sales" }
]
Now imagine we want to:
  • bring the department information into the employee documents,
  • consider employees earning at least ₹50,000,
  • calculate the average salary for each department,
  • sort departments by their average salary,
  • and return only the fields we need.
We can do all of that with one aggregation pipeline:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
db.employees.aggregate([
{
$lookup: {
from: "departments",
localField: "departmentId",
foreignField: "_id",
as: "departmentInfo"
}
},
{
$unwind: "$departmentInfo"
},
{
$match: {
salary: { $gte: 50000 }
}
},
{
$group: {
_id: "$departmentInfo.departmentName",
averageSalary: {
$avg: "$salary"
}
}
},
{
$sort: {
averageSalary: -1
}
},
{
$project: {
_id: 0,
department: "$_id",
averageSalary: 1
}
}
])
If you look at this for the first time, it can still look like a lot.
So instead of trying to understand the whole thing at once, let's follow the data.

Stage 1: $lookup — Bring Related Data

The employee documents contain a departmentId:
1
2
3
4
5
{
name: "Rahul",
departmentId: 101,
salary: 60000
}
But the actual department name is stored in another collection.
The departments collection contains:
1
2
3
4
{
_id: 101,
departmentName: "IT"
}
This is where $lookup comes in.
1
2
3
4
5
6
7
8
{
$lookup: {
from: "departments",
localField: "departmentId",
foreignField: "_id",
as: "departmentInfo"
}
}
$lookup allows us to bring related data from another collection into the documents being processed.
It is similar to a join in relational databases.
Conceptually:
1
2
3
4
5
Employee
+
Department Information
↓
Employee + Department Information
After $lookup, an employee document can look roughly like this:
1
2
3
4
5
6
7
8
9
10
11
{
name: "Rahul",
departmentId: 101,
salary: 60000,
departmentInfo: [
{
_id: 101,
departmentName: "IT"
}
]
}
Notice something important here:
departmentInfo is an array.
That's why the next stage is useful.

Stage 2: $unwind — Open the Array

The $unwind stage takes the elements inside an array and deconstructs them into individual documents.
Here we have:
1
2
3
4
5
6
7
8
{
departmentInfo: [
{
_id: 101,
departmentName: "IT"
}
]
}
After:
1
2
3
{
$unwind: "$departmentInfo"
}
it becomes:
1
2
3
4
5
6
{
departmentInfo: {
_id: 101,
departmentName: "IT"
}
}
This makes the department information easier to work with in the following stages.
So far, our pipeline has done this:
1
2
3
4
5
6
7
8
9
Employee Data
↓
$lookup
↓
Employee + Department Data
↓
$unwind
↓
Easier-to-use Department Data

Stage 3: $match — Filter the Data

Now we only want employees whose salary is at least ₹50,000.
That's the job of $match.
1
2
3
4
5
{
$match: {
salary: { $gte: 50000 }
}
}
In simple terms:
Keep documents where the salary is greater than or equal to 50,000.
Documents that don't satisfy the condition don't continue through the pipeline.
So we can think of $match as a filter:
1
2
3
4
5
All Employees
↓
$match
↓
Employees meeting the condition
This is one of the easiest stages to understand because its purpose is very similar to filtering data in everyday programming.

Stage 4: $group — Combine and Calculate

Now we want to calculate the average salary for each department.
That's where $group comes in.
1
2
3
4
5
6
7
8
{
$group: {
_id: "$departmentInfo.departmentName",
averageSalary: {
$avg: "$salary"
}
}
}
The important part is:
1
_id: "$departmentInfo.departmentName"
This tells MongoDB:
Group documents according to their department name.
And:
1
$avg: "$salary"
tells MongoDB:
Calculate the average salary within each group.
The result can look like:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
_id: "Sales",
averageSalary: 90000
},
{
_id: "IT",
averageSalary: 70000
},
{
_id: "HR",
averageSalary: 50000
}
]
So $group is doing two things here:
  1. Combining documents into groups.
  2. Performing a calculation on each group.
This is why $group can initially look more complicated than $match. But once you break it down, the idea is straightforward.

Stage 5: $sort — Arrange the Results

Now suppose we want the department with the highest average salary to appear first.
That's where $sort comes in:
1
2
3
4
5
{
$sort: {
averageSalary: -1
}
}
The -1 means descending order.
So our results are arranged from the highest average salary to the lowest.
You can think of $sort simply as:
1
2
3
4
5
Unordered Results
↓
$sort
↓
Ordered Results
If we used 1 instead of -1, the order would be ascending.

Stage 6: $project — Shape the Final Result

Finally, we don't necessarily want to expose the internal _id field.
We want a cleaner result like:
1
2
3
4
{
department: "IT",
averageSalary: 70000
}
That's where $project comes in:
1
2
3
4
5
6
7
{
$project: {
_id: 0,
department: "$_id",
averageSalary: 1
}
}
Here we're essentially saying:
Don't show _id, rename it to department, and keep averageSalary.
The final result becomes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[
{
department: "Sales",
averageSalary: 90000
},
{
department: "IT",
averageSalary: 70000
},
{
department: "HR",
averageSalary: 50000
}
]
And that's it.
We started with individual employee documents and ended with a summarized, sorted result.

The Part That Made It Click for Me: Follow the Data

This is probably the most important thing I learned while working with aggregation pipelines.
Instead of staring at the entire pipeline, follow what happens to the data.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Employees
│
▼
$lookup
Bring department information
│
▼
Employees + Department Information
│
▼
$unwind
Turn the department array into a document
│
▼
$match
Keep salaries ≥ ₹50,000
│
▼
Filtered Employees
│
▼
$group
Calculate average salary per department
│
▼
Grouped Results
│
▼
$sort
Arrange by average salary
│
▼
Sorted Results
│
▼
$project
Show only the required fields
│
▼
Final Result
Once you look at the pipeline this way, it doesn't feel like one giant operation anymore.
It's simply:
1
2
3
4
5
6
7
8
9
10
11
Input
↓
Operation
↓
Output
↓
Operation
↓
Output
↓
Final Output
The output of one stage becomes the input of the next.
That is the pipeline.

Why Does Aggregation Feel Complicated at First?

After understanding the example, I think there are a few reasons aggregation can initially feel intimidating.

Multiple operators appear together

Seeing this:
1
2
3
4
5
6
$lookup
$unwind
$match
$group
$sort
$project
can make it look like one massive operation.
But it isn't.
It's several smaller operations chained together.
Each operator has its own responsibility.

The syntax can hide a simple idea

For example:
1
2
3
4
5
6
7
8
{
$group: {
_id: "$departmentInfo.departmentName",
averageSalary: {
$avg: "$salary"
}
}
}
can look complicated when you first see it.
But the idea behind it is simply:
Group employees by department and calculate their average salary.
The syntax is just how we tell MongoDB to perform that operation.

The shape of the data keeps changing

This is another important part.
Before $group, we might have individual employee documents:
1
2
3
4
5
6
7
{
name: "Rahul",
salary: 60000,
departmentInfo: {
departmentName: "IT"
}
}
After $group, we're no longer dealing with individual employees in the same way.
Now we have something like:
1
2
3
4
{
_id: "IT",
averageSalary: 70000
}
The structure of the data has changed.
And this happens throughout the pipeline.
That's why one of the best questions you can ask while reading an aggregation pipeline is:
What does my data look like after this stage?

A Few Other Useful Aggregation Stages

Once the basic idea of the pipeline is clear, other aggregation stages become easier to explore.
StagePurpose
$matchFilters documents
$groupCombines documents and performs calculations
$sortArranges documents
$projectSelects or reshapes fields
$lookupBrings related data from another collection
$unwindDeconstructs an array into individual documents
$limitLimits the number of documents
$skipSkips a number of documents
There are many more aggregation stages, but you don't need to understand all of them at once.
Once you understand the pipeline concept, learning new stages becomes much easier because you can now understand the answer to the question:
What job does this stage perform?

Don't Read the Pipeline as One Huge Query

One thing I would change in the way I approached aggregation initially is how I read the code.
Instead of reading this:
1
2
3
db.employees.aggregate([
// many stages...
])
as one giant query, I now mentally break it apart.
For example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$lookup
"What information do I need?"

↓

$unwind
"How is that information structured?"

↓

$match
"Which documents should continue?"

↓

$group
"What should I combine or calculate?"

↓

$sort
"How should I arrange the result?"

↓

$project
"What should the final output look like?"
This makes the pipeline much easier to reason about.
You're no longer trying to understand six stages simultaneously.
You're solving six small problems one after another.

How I Would Approach Aggregation Now

If I were learning aggregation again, I wouldn't start by trying to memorize every aggregation operator.
I'd start with the data and the question I want to answer.
For example:

1. What data do I start with?

Understand the documents you're working with.

2. What do I want to filter?

If some documents shouldn't continue, $match might be useful.

3. Do I need data from another collection?

If so, $lookup might be involved.

4. Do I need to combine documents or calculate something?

That's where stages such as $group can come in.

5. How should the results be arranged?

Maybe $sort is needed.

6. What should the final output look like?

That's where $project can help.
This approach feels much more natural than trying to memorize a list of operators first.

Don't Just Read the Pipeline — Observe It

Another thing that helped me was actually looking at the intermediate results.
If your pipeline contains:
1
2
3
4
5
6
7
8
9
10
11
$lookup
↓
$unwind
↓
$match
↓
$group
↓
$sort
↓
$project
don't just look at the final result.
Try to understand what the documents look like after each stage.
For example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
After $lookup
↓
What new information appeared?

After $unwind
↓
How did the array change?

After $match
↓
Which documents disappeared?

After $group
↓
How did individual documents become groups?

After $sort
↓
How did their order change?

After $project
↓
What does the final structure look like?
This turns aggregation from something you simply read into something you can actually trace.
And once you can trace the data, the pipeline becomes much easier to understand.

Final Takeaway

When I first saw MongoDB aggregation pipelines, I thought the difficulty was mainly the syntax.
After working with them, I realized that the bigger challenge was trying to understand all the stages at once.
Once I started following the data from one stage to the next, aggregation became much easier to reason about.
An aggregation pipeline isn't one giant operation.
It's a sequence of smaller operations, where each stage has a specific job.
So when you encounter a pipeline that looks complicated, don't immediately try to understand everything.
Start with one stage.
Ask what came in.
Ask what that stage did.
Then ask what came out.
And move to the next stage.
"Don't try to understand the entire pipeline at once. Follow the data, understand one stage at a time, and let the pipeline build itself in your head."
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article