MongoDB $unwind Array Aggregation

Learn how to flatten arrays in MongoDB aggregation pipelines with the $unwind stage. This tutorial covers syntax, use cases, and common pitfalls.

Focus: mongodb $unwind

Sponsored

You've mastered grouping, filtering, and shaping documents inside MongoDB's aggregation pipeline. But what happens when a single field holds an array of scores, tags, or line items — and you need each element to act like its own document? Pivot tables in SQL won't help, and a monolithic array blocks any per-element analysis. This lesson introduces the $unwind stage, the aggregation operator that flattens arrays into individual documents and unlocks a new class of analytics and ETL patterns in MongoDB.

The problem this lesson solves

Consider a collection of orders where each order contains an array of items. You want to compute total revenue per product, or find the most popular item, or count how many orders include a specific SKU. Without $unwind, every aggregation stage treats the entire items array as one value — grouping by items.name gives you the whole array, not each element. You're stuck with a single row per order, unable to see inside the array.

The scenario is everywhere: user profiles with embedded preferences, IoT devices pushing sensor batches, content platforms storing comment threads, and inventory systems with stock levels per warehouse. The $unwind stage transforms array-embedded data into a shape that the rest of the pipeline can process element-by-element.

Core concept / mental model

What $unwind does: For each input document, it deconstructs an array field and outputs one document per array element. If the array is empty, the document is dropped by default (or preserved with preserveNullAndEmptyArrays).

Think of it like unrolling a sheet of stamps — each stamp becomes its own document. Or, if you're from a relational background, think of joining a parent table to a child table where the child rows were previously packed into a single JSON array column.

Where it sits: $unwind is almost always used before $group, $sort, or $project stages that need per-element data. It's a mid-pipeline transformation, not typically a terminal stage.

Default behavior: By default, $unwind drops documents with null, missing, or empty arrays. You can opt into keeping them with the preserveNullAndEmptyArrays: true option.

How it works step by step

  1. Identify the array field — the path is passed as a string starting with $, e.g., "$items". Nested arrays work too: "$details.scores".
  2. Deconstruct — the stage outputs one document per array element, duplicating all other fields.
  3. Create a reference — optionally, includeArrayIndex adds a field with the zero-based index of the element in the original array.
  4. Handle nulls/empty — choose whether to drop or keep such documents.
  5. Chain downstream stages — now each element is accessible as a regular field, ready for $group, $sort, $project, or $match.

Let's model a concrete example. Suppose we have an orders collection:

{ "_id": 1, "customer": "Alice", "items": [
  { "name": "Laptop", "qty": 1, "price": 1200 },
  { "name": "Mouse", "qty": 2, "price": 25 }
]}

Applying { $unwind: "$items" } produces two documents:

{ "_id": 1, "customer": "Alice", "items": { "name": "Laptop", "qty": 1, "price": 1200 } }
{ "_id": 1, "customer": "Alice", "items": { "name": "Mouse", "qty": 2, "price": 25 } }

The array is gone; each element is now the value of items. Now a $group on $items.name will work as intended.

Hands-on walkthrough

Let's build a pipeline from start to finish. Assume a collection orders:

// Sample data
{ "_id": 1, "items": [ { "name": "apple", "qty": 5 }, { "name": "banana", "qty": 2 } ] }
{ "_id": 2, "items": [ { "name": "apple", "qty": 3 } ] }
{ "_id": 3, "items": [] }

The basic $unwind step:

db.orders.aggregate([
  { $unwind: "$items" }
])

Expected output (document 3 is dropped because its array is empty):

{ "_id": 1, "items": { "name": "apple", "qty": 5 } }
{ "_id": 1, "items": { "name": "banana", "qty": 2 } }
{ "_id": 2, "items": { "name": "apple", "qty": 3 } }

Now let's see the power: compute total quantity per item.

db.orders.aggregate([
  { $unwind: "$items" },
  { $group: { _id: "$items.name", totalQty: { $sum: "$items.qty" } } },
  { $sort: { totalQty: -1 } }
])

Expected output:

{ "_id": "apple", "totalQty": 8 }
{ "_id": "banana", "totalQty": 2 }

To keep empty-array documents, use the option:

db.orders.aggregate([
  { $unwind: { path: "$items", preserveNullAndEmptyArrays: true } }
])

Now document 3 appears, with items as null. This is handy when you still want to count orders without items.

You can also track the original index:

db.orders.aggregate([
  { $unwind: { path: "$items", includeArrayIndex: "idx" } }
])

This adds an idx field (0 for first element, 1 for second). Use it when element order matters.

Compare options / when to choose what

Approach Use case Pros Cons
$unwind (default) Want to analyze each element individually Simple, efficient Drops empty arrays
$unwind with preserveNullAndEmptyArrays: true Must keep documents even if array empty Retains context More documents to process
$arrayElemAt / $slice Only need one or a few specific elements No document multiplication Limited to positional access
$map / $reduce (inside $project) Need aggregated result without flattening Single document output Harder to read, heavier computation

Recommendation: If you need per-element grouping, filtering, or sorting, $unwind is the way. If you just need the first element or a summed total, consider $arrayElemAt or $reduce to avoid pipeline explosion.

Pro tip: $unwind on a large array can dramatically increase the number of intermediate documents. For analytics over millions of documents, be mindful of memory and latency. Use $match early to filter before unwinding when possible.

Troubleshooting & edge cases

  • Pipe symbol in field path: If your field name contains a dot, use $ prefix with the full path, but you may need escaping — generally avoid dots in field names.
  • Property does not exist: By default, documents without the array field are dropped. If you expected them, enable preserveNullAndEmptyArrays.
  • Array nested inside another array: $unwind only flattens one level. To flatten a nested array, chain multiple $unwind stages, one per level.
  • Performance issues: Unwinding a huge array can create billions of documents. Use $match before $unwind to reduce input. Consider rewriting the schema for deeply nested arrays.
  • Index doesn't help: $unwind cannot use indexes on the array elements because it's a transformation. Pre-filter with $match on top-level fields to reduce documents.

Example of a common error — trying to access an array element without unwinding:

// Wrong: groups by array object, not by each name
db.orders.aggregate([
  { $group: { _id: "$items.name", total: { $sum: 1 } } }
])

$group sees an array of names, not a string, so _id becomes the whole array — grouping won't split it.

What you learned & what's next

You now understand how $unwind flattens arrays into individual documents, enabling per-element aggregations. You practiced the basic syntax, used preserveNullAndEmptyArrays and includeArrayIndex, and compared it with other array operators. You also know how to avoid dropping empty arrays and handle nested cases.

Next lesson in the track will explore $lookup — how to join collections in the pipeline, opening even richer aggregation possibilities. With $unwind in your toolkit, you're ready to combine it with $lookup to flatten joined results.

Key takeaway: $unwind is your go-to when an array holds data that should behave like records. Use it wisely with early $match to keep pipelines performant.

Now try building a pipeline that unwinds customer tags and counts tag frequency — you'll see the pattern click into place.

Practice recap

Now try on your own: create a collection of student records with a grades array (e.g., { name: "Ava", grades: [85, 92, 78] }). Write a pipeline that unwinds the grades, then groups to find the average grade per student and sorts by average. Test with a student who has an empty grades array and see how preserveNullAndEmptyArrays changes the result. This will solidify your understanding of the stage's behavior.

Common mistakes

  • Not using preserveNullAndEmptyArrays: true when you need to keep documents with empty arrays — the default drops them silently.
  • Assuming $unwind flattens nested arrays of arrays — it only goes one level deep; chain multiple $unwind stages.
  • Placing $unwind after a $group that needs per-element aggregation — order matters; unwind before grouping.
  • Using $unwind on non-array fields (like a scalar or object) — produces an error; validate your schema.
  • Forgetting to use $ before the field path — $unwind expects a path string like "$items".

Variations

  1. Use $unwind with includeArrayIndex to preserve element positions for ordered analysis.
  2. Leverage $arrayElemAt or $slice when you only need a single element or a subset, avoiding document multiplication.
  3. Combine $unwind with $lookup to flatten results of a left join — a powerful pattern for relational-style queries.

Real-world use cases

  • E-commerce: compute revenue per product by unwinding order line items and grouping by product SKU.
  • IoT: analyze sensor batch readings by unwinding an array of measurements per device document.
  • Analytics: count tag frequencies across user profiles by unwinding the tags array and grouping by tag value.

Key takeaways

  • $unwind deconstructs an array into one document per element, duplicating sibling fields.
  • Default behavior drops documents with missing, null, or empty arrays; use preserveNullAndEmptyArrays to keep them.
  • Chain $unwind before $group, $sort, or $match stages that need per-element access.
  • Use includeArrayIndex to preserve the original element position when order matters.
  • Filter early with $match to reduce input size and keep $unwind performant on large datasets.
  • For nested arrays, apply multiple $unwind stages — one per level of nesting.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.