MongoDB$unwind Stage

$unwind

$unwind deconstructs an array field, producing one output document per array element. It's the pipeline stage you reach for whenever you need to treat array elements as independent rows — most commonly right before a $group that tallies something across those elements.

Basic Flattening

One document becomes many

JS
db.posts.insertOne({ _id: 1, title: "Intro to MongoDB", tags: ["mongodb", "nosql", "database"] })

db.posts.aggregate([{ $unwind: "$tags" }])
[
  { _id: 1, title: 'Intro to MongoDB', tags: 'mongodb' },
  { _id: 1, title: 'Intro to MongoDB', tags: 'nosql' },
  { _id: 1, title: 'Intro to MongoDB', tags: 'database' }
]

Notice tags is no longer an array in the output — each document gets exactly one scalar value from the original array, with every other field copied unchanged.

preserveNullAndEmptyArrays

By default, $unwind drops any document where the array field is missing, null, or an empty array — there's nothing to "unwind" into. Set preserveNullAndEmptyArrays: true to keep those documents instead, with the field set to null.

Keeping documents with no array

JS
db.posts.aggregate([
  {
    $unwind: {
      path: "$tags",
      preserveNullAndEmptyArrays: true
    }
  }
])
// A post with tags: [] or no "tags" field at all now appears once,
// with tags: null, instead of disappearing from the results.
Warning
Without preserveNullAndEmptyArrays, documents lacking the array field silently vanish from the pipeline. This is a common source of "missing data" bug reports — always consider whether a document with an empty/absent array should still count toward downstream totals.
includeArrayIndex

Add includeArrayIndex to capture the original position of each element — useful when order matters downstream (e.g. reconstructing rank, or the original array position for debugging).

Capturing the original array index

JS
db.posts.aggregate([
  {
    $unwind: {
      path: "$tags",
      includeArrayIndex: "tagIndex"
    }
  }
])
// { _id: 1, tags: "mongodb", tagIndex: 0 }
// { _id: 1, tags: "nosql",   tagIndex: 1 }
// { _id: 1, tags: "database",tagIndex: 2 }
The Unwind + Group Pattern: Tag Counts

The single most common use of $unwind is immediately followed by $group — flatten an array, then tally something per element. This is how you compute a tag cloud, a "top categories" list, or per-item revenue from an order's line items.

Counting how often each tag appears across all posts

JS
db.posts.aggregate([
  { $unwind: "$tags" },
  { $group: { _id: "$tags", count: { $sum: 1 } } },
  { $sort: { count: -1 } }
])
// [ { _id: "mongodb", count: 45 }, { _id: "nosql", count: 30 }, ... ]

Revenue per product across every order's line items

JS
db.orders.aggregate([
  { $unwind: "$items" },
  {
    $group: {
      _id: "$items.sku",
      totalRevenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } },
      unitsSold: { $sum: "$items.qty" }
    }
  },
  { $sort: { totalRevenue: -1 } }
])
Performance and Document-Explosion Warning
Warning
$unwind multiplies the number of documents flowing through the pipeline by the array's length — a 10,000-document collection where each document has a 50-element array becomes 500,000 intermediate documents. This can blow past memory limits in later stages and slow the pipeline dramatically. Filter with an early $match before unwinding whenever possible, and unwind only the array you actually need.

Filter before you unwind

JS
db.orders.aggregate([
  { $match: { status: "shipped", createdAt: { $gte: ISODate("2024-01-01") } } },  // shrink first
  { $unwind: "$items" },   // now unwinding a much smaller set of documents
  { $group: { _id: "$items.sku", unitsSold: { $sum: "$items.qty" } } }
])
Alternatives That Avoid Unwinding

If you don't actually need one-document-per-element — you just need to compute something across the array — the array expression operators ($map, $filter, $reduce) can often do the job without exploding the document count at all.

Computing order total without $unwind

JS
db.orders.aggregate([
  {
    $project: {
      total: {
        $sum: {
          $map: {
            input: "$items",
            as: "item",
            in: { $multiply: ["$$item.qty", "$$item.price"] }
          }
        }
      }
    }
  }
])
// One output document per input document — no explosion, no $unwind needed

Approach

Document Count

Best For

$unwind + $group

Explodes, then collapses across the WHOLE collection

Aggregating a value ACROSS all documents (tag counts, per-SKU revenue)

$map / $filter / $reduce

Stays at one document per input document

Computing a value WITHIN each document (e.g. that order's own total)

Tip
Ask "do I need to summarize across many documents, or just compute something within each single document?" The first calls for $unwind + $group; the second is almost always cheaper with $map/$filter/$reduce and no $unwind at all.
  • $unwind produces one document per array element, copying every other field unchanged.

  • preserveNullAndEmptyArrays: true keeps documents whose array is missing/empty instead of dropping them.

  • includeArrayIndex captures each element's original position.

  • Unwind + group is the standard pattern for cross-document array aggregation (tag counts, per-item revenue).

  • Filter with $match before $unwind to avoid exploding a large array across a large collection.