Learn how $unwind splits arrays into separate documents for grouping, and how array operators like $size, $filter, $map and $sortArray work without unwinding.

Bishal Biswas
Self Learner
Before you start: this lesson assumes you’ve read Core Stages: match, project, group and sort and Joining Collections with lookup. It uses the
shopdata from Aggregation Pipeline Fundamentals.
What you will learn
By the end of this lesson you will know:
- What
$unwinddoes and why you need it to group array elements - How to find best-selling products from order line items
- The
$unwindoptionsincludeArrayIndexandpreserveNullAndEmptyArrays - How to work on arrays without unwinding, using
$size,$filter,$map,$sortArrayand friends - When to unwind and when not to
The problem: answers hidden inside arrays
Each order has an items array. Order 1 looks like this:
{
_id: 1,
total: 158,
items: [
{ productId: ObjectId("66b000000000000000000001"), name: "Notebook", qty: 2, price: 49 },
{ productId: ObjectId("66b000000000000000000002"), name: "Gel Pen", qty: 4, price: 15 }
],
...
}
Now the manager asks: “How many Notebooks have we sold in total?”
$group can’t help directly, because a group key like "$items.name" would be the whole list ["Notebook", "Gel Pen"], not one product. We need each item on its own.
Think of it like this: Imagine a box of 2 items riding on the conveyor belt. A worker opens the box and puts each item on the belt separately, with a copy of the box’s label (order number, date, customer) stuck on each one. That worker is
$unwind.
$unwind: one document per array element
db.orders.aggregate([
{ $match: { _id: 1 } },
{ $unwind: "$items" },
{ $project: { _id: 1, status: 1, items: 1 } }
])
Before $unwind: 1 document with an array of 2 items.
After $unwind: 2 documents, each with one item. items is now an object, not an array:
[
{ _id: 1, status: "delivered",
items: { productId: ObjectId("66b000000000000000000001"), name: "Notebook", qty: 2, price: 49 } },
{ _id: 1, status: "delivered",
items: { productId: ObjectId("66b000000000000000000002"), name: "Gel Pen", qty: 4, price: 15 } }
]
Notice both documents have _id: 1. That’s fine. They’re temporary documents on the belt, not saved anywhere.
Across all 6 orders, $unwind: "$items" produces 9 documents, one per line item.
Best sellers: unwind, then group
Now each item is its own document, so we can group by product name:
db.orders.aggregate([
{ $unwind: "$items" },
{ $group: {
_id: "$items.name",
unitsSold: { $sum: "$items.qty" },
revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } }
} },
{ $sort: { revenue: -1 } }
])
// → [
// { _id: "Headphones", unitsSold: 2, revenue: 3998 },
// { _id: "Coffee Mug", unitsSold: 3, revenue: 747 },
// { _id: "Notebook", unitsSold: 7, revenue: 343 },
// { _id: "USB Cable", unitsSold: 1, revenue: 299 },
// { _id: "Gel Pen", unitsSold: 6, revenue: 90 }
// ]
So we sold 7 Notebooks. Notebooks win on units, but Headphones win on money.
This “unwind, then group” pattern is one of the most common in MongoDB. You’ll write it again and again.
Unwind + lookup: revenue per category
Order items don’t store a category, but products do. Unwind the items, then join each one to its product:
db.orders.aggregate([
{ $unwind: "$items" },
{ $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "product" } },
{ $set: { product: { $first: "$product" } } },
{ $group: {
_id: "$product.category",
revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } }
} },
{ $sort: { revenue: -1 } }
])
// → [
// { _id: "electronics", revenue: 4297 },
// { _id: "kitchen", revenue: 747 },
// { _id: "stationery", revenue: 433 }
// ]
A shortcut: $sortByCount
“Unwind, group, count, sort by count” is so common that it has its own stage. Which product tags are most popular?
db.products.aggregate([
{ $unwind: "$tags" },
{ $sortByCount: "$tags" }
])
// → [ { _id: "school", count: 2 }, { _id: "paper", count: 1 }, { _id: "pen", count: 1 }, ... ]
$sortByCount: "$tags" is the same as $group by tag with count: { $sum: 1 }, followed by $sort: { count: -1 }. Tags with the same count can appear in any order.
$unwind options
The short form { $unwind: "$items" } is enough most of the time. The long form gives you two extra switches:
{ $unwind: {
path: "$items",
includeArrayIndex: "line", // optional: store the element's position
preserveNullAndEmptyArrays: true // optional: keep documents with no elements
} }
includeArrayIndex: remember the position
db.orders.aggregate([
{ $match: { _id: 4 } },
{ $unwind: { path: "$items", includeArrayIndex: "line" } },
{ $project: { _id: 1, line: 1, item: "$items.name" } }
])
// → [
// { _id: 4, line: Long("0"), item: "USB Cable" },
// { _id: 4, line: Long("1"), item: "Gel Pen" }
// ]
Positions start at 0. The value is stored as a long integer, which mongosh shows as Long("0"). (Need the position without unwinding? See Array Index Access with IDX and arrayIndexAs.)
preserveNullAndEmptyArrays: don’t lose anyone
By default, $unwind drops a document if the array is missing, null or empty. There’s nothing to put on the belt, so the document simply vanishes. Watch Maria, who has no orders:
db.customers.aggregate([
{ $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } },
{ $unwind: "$orders" },
{ $group: { _id: "$name", spent: { $sum: "$orders.total" } } }
])
// → Asha 2157, Ken 2248, Priya 574, Rahul 498 (no Maria!)
Add preserveNullAndEmptyArrays: true and she stays, with no orders field:
db.customers.aggregate([
{ $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } },
{ $unwind: { path: "$orders", preserveNullAndEmptyArrays: true } },
{ $group: { _id: "$name", spent: { $sum: "$orders.total" } } }
])
// → Asha 2157, Ken 2248, Maria 0, Priya 574, Rahul 498
Warning: Silently dropped documents are one of the sneakiest bugs in reports. Whenever you
$unwind, ask yourself: “Can this array be empty? Do I still want those documents?”
Note: If the field is not an array but a single value (for example
tags: "gift"),$unwindtreats it like an array of one element.
Putting arrays back together
After unwinding, you can rebuild arrays with $group and $push. Here we keep only order lines with a quantity of 2 or more, then pack them back per order:
db.orders.aggregate([
{ $unwind: "$items" },
{ $match: { "items.qty": { $gte: 2 } } },
{ $group: { _id: "$_id", bigItems: { $push: "$items.name" } } },
{ $sort: { _id: 1 } }
])
// → [
// { _id: 1, bigItems: ["Notebook", "Gel Pen"] },
// { _id: 3, bigItems: ["Coffee Mug"] },
// { _id: 4, bigItems: ["Gel Pen"] },
// { _id: 6, bigItems: ["Notebook"] }
// ]
It works, but notice two things. Orders 2 and 5 disappeared (they had no big items). And we lost every other field of the order. There’s a better way for jobs like this.
Array operators: work inside the box
Many array questions are about one document at a time: “how many items in this order?”, “which items in this order are big?”. For these, you don’t need to unwind. Array expression operators work on the array right where it is, inside $project or $set.
Think of it like this: Instead of tipping the box out onto the belt, the worker reaches into the box, does the job, and closes it again. The box stays one box.
The same “big items” question, without unwinding:
db.orders.aggregate([
{ $project: {
bigItems: {
$filter: { input: "$items", as: "item", cond: { $gte: ["$$item.qty", 2] } }
}
} }
])
// → order 1: [Notebook, Gel Pen lines], order 2: [], order 3: [Coffee Mug line], ...
Every order is still there (order 2 just gets an empty list), and you could keep any other fields too. $$item is the variable name we chose with as, pointing at the current element.
More useful array operators
Here’s a pipeline that shows several at once:
db.orders.aggregate([
{ $match: { _id: 1 } },
{ $project: {
itemCount: { $size: "$items" }, // how many lines
units: { $sum: "$items.qty" }, // total quantity
firstItem: { $first: "$items.name" }, // first line's name
hasGelPen: { $in: ["Gel Pen", "$items.name"] }, // is it in the list?
checkTotal: { $sum: { $map: { // recompute the total
input: "$items", as: "i",
in: { $multiply: ["$$i.qty", "$$i.price"] } } } },
priciest: { $first: { $sortArray: { input: "$items", sortBy: { price: -1 } } } }
} }
])
// → [
// { _id: 1, itemCount: 2, units: 6, firstItem: "Notebook", hasGelPen: true, checkTotal: 158,
// priciest: { productId: ObjectId("66b000000000000000000001"), name: "Notebook", qty: 2, price: 49 } }
// ]
checkTotal matches the stored total of 158, so the order is correct. Handy for data checks!
Note:
"$items.qty"on an array of documents gives you an array of the values:[2, 4]. That’s why$sum: "$items.qty"works.
A quick reference:
| Operator | What it does | Since |
|---|---|---|
$size |
Number of elements | |
$arrayElemAt |
Element at a position: { $arrayElemAt: ["$items", 0] } |
|
$first / $last |
First / last element | 4.4 |
$slice |
A part of the array: { $slice: ["$items", 2] } |
|
$in |
Is a value in the array? | |
$filter |
Keep elements that pass a test | |
$map |
Transform every element | |
$reduce |
Combine all elements into one value | |
$concatArrays |
Join arrays end to end | |
$setUnion |
Merge arrays, removing duplicates | |
$sortArray |
Sort the elements | 5.2 |
$firstN / $lastN / $maxN / $minN |
First, last, biggest or smallest N elements | 5.2 |
Unwind or array operator?
| Your question is about… | Use |
|---|---|
| Elements across many documents (“total Notebooks sold”) | $unwind + $group |
| Elements within one document (“items in this order”) | Array operators in $project/$set |
| Joining each element to another collection | $unwind + $lookup (or $lookup on the array field) |
Tip:
$unwindmultiplies documents. 1 million orders with 10 items each become 10 million documents on the belt. Always$matchbefore$unwind, and prefer array operators when you don’t really need to group across documents.
Common mistakes
- Losing documents with empty arrays.
$unwinddrops them by default. UsepreserveNullAndEmptyArrays: truewhen they matter. - Forgetting that
itemsis an object after unwinding. Write"$items.qty"(works on both), but don’t expect$size: "$items"to work after$unwind. It’s no longer an array. - Unwinding to answer a per-document question. Use
$filter,$map,$sizeinstead. It’s simpler and faster. - Unwinding before filtering. Put
$matchfirst so fewer documents get multiplied. - Using
$instead of$$for the element variable. Inside$filter/$map, the current element is"$$item"(two dollar signs), not"$item".
Practice
- Find the total quantity of each product sold, but only from delivered orders.
- For each order, add a field
lineCount(number of items) andnames(array of item names), without$unwind. - Which customers have bought a
"Gel Pen"? Show theircustomerIdonly once each.
Show a possible answer
// 1. Units sold from delivered orders
db.orders.aggregate([
{ $match: { status: "delivered" } }, // filter BEFORE unwinding
{ $unwind: "$items" },
{ $group: { _id: "$items.name", units: { $sum: "$items.qty" } } },
{ $sort: { units: -1 } }
])
// → Gel Pen 4, Coffee Mug 3, Notebook 2, Headphones 1
// 2. Without unwinding
db.orders.aggregate([
{ $set: { lineCount: { $size: "$items" }, names: "$items.name" } }
])
// 3. Gel Pen buyers
db.orders.aggregate([
{ $match: { "items.name": "Gel Pen" } },
{ $group: { _id: "$customerId" } }
])
// → Asha (66a...0001) and Priya (66a...0003)
Key takeaways
$unwindturns one document with an array of N elements into N documents, each with one element.- Unwind, then group answers questions across many documents, like best sellers.
includeArrayIndexkeeps the position;preserveNullAndEmptyArrays: truekeeps documents with empty or missing arrays.- Array operators (
$size,$filter,$map,$sortArray,$in…) work inside one document without unwinding. $matchbefore$unwind, because unwinding multiplies the number of documents.
Next lesson: facet, bucket and Multi-Result Pipelines
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready