$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 $unwind is first).
  • It evaluates the path field 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 — unless preserveNullAndEmptyArrays is true, in which case one document is kept, with the array field omitted or nulled out.
  • If includeArrayIndex is 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 $match stages before $unwind whenever the filter doesn’t need the unwound field, so the pipeline explodes fewer documents.
  • Use preserveNullAndEmptyArrays: true whenever missing or empty arrays are a normal, expected case in your data — don’t let those documents disappear silently.
  • Use includeArrayIndex when you need to know an element’s original position, for example to target it later with a positional update operator.
  • Remember that $unwind multiplies your document count — a $count stage placed after $unwind counts 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 orders collection above, write a pipeline that unwinds items and returns only the line items where qty is greater than 5. Expect a single document for the "Pen" item from Ravi’s order.
  • Add a document to orders with no items field at all. Write a pipeline using $unwind that still includes this order in the output, with items absent, rather than dropping it.
  • Using $unwind with includeArrayIndex, find only the first item (index 0) of every order. Hint: unwind with the index field, then $match on that field being 0.

Summary

  • $unwind turns 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 supporting includeArrayIndex and preserveNullAndEmptyArrays.
  • By default, documents with a missing, null, or empty array field are dropped from the output — use preserveNullAndEmptyArrays: true to keep them.
  • $unwind is most often paired with $group to aggregate values stored inside embedded arrays.
  • Placement matters: filter with $match before $unwind whenever 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.