$unwind
Documents in MongoDB often contain arrays — an order with multiple line items, a blog post with multiple tags, a student with multiple test scores. Most of the time you want to work with that array as a single unit, but sometimes you need to analyze the array’s individual elements: total revenue per product across all orders, or a count of how often each tag appears. The $unwind aggregation stage is the tool that makes this possible — it takes a document with an array field and "flattens" it into one output document per array element.
Overview / How it works
$unwind deconstructs an array field from the input documents and outputs one document for every element of that array. Every other field in the original document is copied unchanged into each output document, and the array field itself is replaced with a single element from that array (no longer an array, just the raw value). If a document has an array of 3 elements, $unwind turns it into 3 separate documents; if the array has 0 elements, that document is dropped from the output entirely by default.
This matters because most later aggregation stages — particularly $group, $sort, and $match — operate on one field value per document, not on the contents of an array. If you want to sum the price of every item across every order, you can’t easily do that while items are nested inside an array; MongoDB’s arithmetic accumulators like $sum expect a scalar value per document in the pipeline stream. $unwind solves this by turning "one document with an array of 3 items" into "3 documents, each with one item," so that a following $group stage can iterate over items directly.
Internally, the aggregation pipeline processes documents as a stream: each stage reads documents from the previous stage, transforms them, and passes them downstream. $unwind is a one-to-many transformation — for every input document it can emit zero, one, or many output documents, which is different from stages like $match or $project that always emit at most one output document per input document. This is important for performance: unwinding a large array multiplies the number of documents flowing through the rest of the pipeline, so where you place $unwind relative to other stages has a real cost.
Syntax
$unwind has two forms: a shorthand string form, and a full document form with extra options.
// Shorthand form
db.orders.aggregate([ { $unwind: "$items" } ]);
// Full document form
db.orders.aggregate([
{
$unwind: {
path: "$items",
includeArrayIndex: "itemIndex",
preserveNullAndEmptyArrays: true
}
}
]);
| Option | Type | Description |
|---|---|---|
path |
string | Required. The array field to unwind, written as a field path prefixed with $ (e.g. "$items"). In the shorthand form, this is the entire value passed to $unwind. |
includeArrayIndex |
string | Optional. The name of a new field to add to each output document, containing the numeric index (starting at 0) of the element within the original array. |
preserveNullAndEmptyArrays |
boolean | Optional, defaults to false. If true, documents where the field is missing, null, or an empty array are kept in the output (with the array field omitted or set to null) instead of being dropped. |
Examples
All examples use this orders collection, where each order embeds a list of purchased items:
db.orders.insertMany([
{
_id: 1,
customer: "Ravi",
items: [
{ product: "Notebook", qty: 3, price: 2.5 },
{ product: "Pen", qty: 10, price: 0.5 }
]
},
{
_id: 2,
customer: "Meera",
items: [ { product: "Notebook", qty: 1, price: 2.5 } ]
},
{ _id: 3, customer: "Anil", items: [] }
]);
Example 1: Basic unwind
db.orders.aggregate([ { $unwind: "$items" } ]);
Output:
[
{ _id: 1, customer: 'Ravi', items: { product: 'Notebook', qty: 3, price: 2.5 } },
{ _id: 1, customer: 'Ravi', items: { product: 'Pen', qty: 10, price: 0.5 } },
{ _id: 2, customer: 'Meera', items: { product: 'Notebook', qty: 1, price: 2.5 } }
]
Order 1’s two-element array becomes two documents, each keeping _id and customer but with items replaced by a single item object. Order 2 becomes one document. Order 3 has an empty array, so by default it disappears from the output completely — a behavior that trips people up, as covered in Common Mistakes below.
Example 2: Keeping the array index and empty arrays
db.orders.aggregate([
{
$unwind: {
path: "$items",
includeArrayIndex: "itemIndex",
preserveNullAndEmptyArrays: true
}
}
]);
Output:
[
{ _id: 1, customer: 'Ravi', items: { product: 'Notebook', qty: 3, price: 2.5 }, itemIndex: 0 },
{ _id: 1, customer: 'Ravi', items: { product: 'Pen', qty: 10, price: 0.5 }, itemIndex: 1 },
{ _id: 2, customer: 'Meera', items: { product: 'Notebook', qty: 1, price: 2.5 }, itemIndex: 0 },
{ _id: 3, customer: 'Anil', itemIndex: null }
]
Now every unwound document carries an itemIndex showing its position in the original array (useful if you later need to update a specific array element by position). Because preserveNullAndEmptyArrays is true, order 3 — whose items array is empty — is kept instead of dropped; MongoDB omits the items field entirely (there was no element to place there) and sets itemIndex to null.
Example 3: Unwind + group for a realistic report
The real power of $unwind shows up when it feeds a $group stage. Here we compute total quantity and revenue per product across all orders:
db.orders.aggregate([
{ $unwind: "$items" },
{
$group: {
_id: "$items.product",
totalQty: { $sum: "$items.qty" },
totalRevenue: { $sum: { $multiply: [ "$items.qty", "$items.price" ] } }
}
},
{ $sort: { totalRevenue: -1 } }
]);
Output:
[
{ _id: 'Notebook', totalQty: 4, totalRevenue: 10 },
{ _id: 'Pen', totalQty: 10, totalRevenue: 5 }
]
After $unwind flattens every order into one document per line item, $group can bucket those documents by items.product and accumulate quantity and revenue with $sum. Without the $unwind step first, $group would have no way to reach into each order’s array and sum across its individual elements.
How it works step by step
- MongoDB reads each input document from the previous stage (or the collection scan, if
$unwindis first). - It evaluates the
pathfield on that document. If the field holds an array with N elements, it emits N new documents — identical to the original except the array field now holds a single element instead of the whole array. - If the field is not an array (a scalar value, e.g. a string), MongoDB treats it as an array of one and simply passes the document through with that value unchanged.
- If the field is missing,
null, or an empty array, the document is dropped — unlesspreserveNullAndEmptyArraysistrue, in which case one document is kept, with the array field omitted or nulled out. - If
includeArrayIndexis set, MongoDB attaches the zero-based position of each element as a new field on its corresponding output document. - All resulting documents are streamed downstream to the next pipeline stage in order.
Common Mistakes
Mistake 1: Silently losing documents with missing or empty arrays
By default, any document whose array field is missing, null, or empty gets dropped from the aggregation output entirely — not an error, just gone. This is easy to miss when the array field is populated for most documents but not all of them.
// Wrong: order 4 has no `items` field at all
db.orders.insertOne({ _id: 4, customer: "Priya" });
// This silently excludes order 4 from the results
db.orders.aggregate([ { $unwind: "$items" } ]);
If you’re later counting customers or orders from this pipeline, order 4 (and its customer, Priya) simply vanishes — no error is raised. Fix it by explicitly preserving those documents:
db.orders.aggregate([
{ $unwind: { path: "$items", preserveNullAndEmptyArrays: true } }
]);
Mistake 2: Unwinding before filtering
Placing $unwind before a $match that only needs top-level fields forces MongoDB to explode every document’s array — potentially multiplying millions of documents into tens of millions — before throwing most of them away.
// Wrong: unwinds every order's items before filtering by customer
db.orders.aggregate([
{ $unwind: "$items" },
{ $match: { customer: "Ravi" } }
]);
Whenever a filter doesn’t depend on the array being unwound, run it first, ideally on an indexed field, so $unwind only has to process the documents that survive the filter:
db.orders.aggregate([
{ $match: { customer: "Ravi" } },
{ $unwind: "$items" }
]);
Best Practices
- Put
$matchstages before$unwindwhenever the filter doesn’t need the unwound field, so the pipeline explodes fewer documents. - Use
preserveNullAndEmptyArrays: truewhenever missing or empty arrays are a normal, expected case in your data — don’t let those documents disappear silently. - Use
includeArrayIndexwhen you need to know an element’s original position, for example to target it later with a positional update operator. - Remember that
$unwindmultiplies your document count — a$countstage placed after$unwindcounts array elements, not original documents. - For very large or unbounded arrays, consider whether the array should be a separate, referenced collection instead of an embedded array — unwinding a 50,000-element array on every document in a large collection is expensive no matter how you order your pipeline.
- When you only need a subset of an array’s elements, filter it with
$filter(or query-time array projection) before unwinding, rather than unwinding everything and filtering afterward.
Practice Exercises
- Given the
orderscollection above, write a pipeline that unwindsitemsand returns only the line items whereqtyis greater than 5. Expect a single document for the "Pen" item from Ravi’s order. - Add a document to
orderswith noitemsfield at all. Write a pipeline using$unwindthat still includes this order in the output, withitemsabsent, rather than dropping it. - Using
$unwindwithincludeArrayIndex, find only the first item (index 0) of every order. Hint: unwind with the index field, then$matchon that field being0.
Summary
$unwindturns one document containing an N-element array into N separate documents, one per array element.- It has a shorthand form (
{ $unwind: "$field" }) and a full document form supportingincludeArrayIndexandpreserveNullAndEmptyArrays. - By default, documents with a missing,
null, or empty array field are dropped from the output — usepreserveNullAndEmptyArrays: trueto keep them. $unwindis most often paired with$groupto aggregate values stored inside embedded arrays.- Placement matters: filter with
$matchbefore$unwindwhenever possible to avoid exploding documents you’ll just discard. - Unwinding large or unbounded arrays is expensive — it’s a signal to reconsider whether that data should be embedded at all.
