← back

$unwind
Array Deconstruction

FILE  21_aggregation_unwind
TOPIC  Document Multiplication · Options · Vanishing Act · Unwind-Rewind Pattern
LEVEL  Foundation
01
What is $unwind
Deconstruct an array field into individual documents
expand

$unwind deconstructs an array field in each input document, outputting one document per array element. A document with an array of N elements produces N output documents, each containing one element in place of the original array.

INPUT (1 document)
{ _id:1, tags:["a","b","c"] }
OUTPUT (3 documents)
{ _id:1, tags:"a" }
{ _id:1, tags:"b" }
{ _id:1, tags:"c" }

Why Use $unwind?

  • Group by array elements — e.g., count documents per tag, per item
  • Filter individual elements — remove/select specific elements from each doc's array
  • Flatten a $lookup result — convert 1-to-1 join array into an embedded object
  • Per-element computation — apply expressions to each element before re-grouping
02
Document Multiplication
Fan-out effect — always $match and $project before $unwind
warning

$unwind is a document fan-out operation. It multiplies documents flowing through the pipeline — one per array element. This is expected behavior but must be managed to avoid hitting memory limits.

// 1 document × 1,000-element array → 1,000 pipeline documents
// 10,000 documents × 100-element arrays → 1,000,000 pipeline documents
DANGER
Always $match to reduce document count AND $project to minimum fields before $unwind. Unwinding 100K documents each with 500-element arrays without pre-filtering = 50 million intermediate documents. This will hit the 100MB stage limit.

Pre-Unwind Optimization

db.articles.aggregate([

  // 1. Filter documents FIRST
  { $match: { status: "published", category: "tech" } },

  // 2. Drop unneeded fields BEFORE expanding
  { $project: { tags: 1, _id: 1 } },

  // 3. NOW expand — fewer docs, fewer fields per doc
  { $unwind: "$tags" },

  // 4. Aggregate on the reduced-and-expanded set
  { $group: { _id: "$tags", count: { $sum: 1 } } },
  { $sort: { count: -1 } }
])
03
Syntax & Options
Simple form and extended form with all options
syntax
// Simple form — most common
{ $unwind: "$arrayField" }

// Extended form — with options
{
  $unwind: {
    path:                       "$arrayField",
    includeArrayIndex:          "indexFieldName",  // optional
    preserveNullAndEmptyArrays: true               // optional, default: false
  }
}
OptionTypeDefaultDescription
path String Required in extended form. The array field path, prefixed with $.
includeArrayIndex String omitted Creates a new field with the element's original zero-based index.
preserveNullAndEmptyArrays Boolean false If true, docs with missing/null/empty array field are kept in output.

Scalar (Non-Array) Field Behavior

// $unwind on a scalar field treats it as a 1-element array — no error
{ _id: 1, tag: "mongodb" }     // single string, not array
db.col.aggregate([{ $unwind: "$tag" }])
// Output: { _id:1, tag:"mongodb" }  — same doc, no multiplication
04
The Vanishing Act
Documents silently disappear by default
gotcha

By default, $unwind silently drops any document where the target array field is null, missing, or an empty array. No error is thrown — documents simply disappear from the pipeline.

// Test data
{ _id: 1, tags: ["mongodb", "nosql"] }  // normal array
{ _id: 2, tags: [] }                     // empty array
{ _id: 3, tags: null }                   // null
{ _id: 4 }                              // field missing

// DEFAULT — docs 2, 3, 4 vanish silently!
db.col.aggregate([{ $unwind: "$tags" }])
// Returns only:
// { _id:1, tags:"mongodb" }
// { _id:1, tags:"nosql" }

// WITH preserveNullAndEmptyArrays:true
db.col.aggregate([{
  $unwind: {
    path: "$tags",
    preserveNullAndEmptyArrays: true
  }
}])
// Returns:
// { _id:1, tags:"mongodb" }
// { _id:1, tags:"nosql" }
// { _id:2 }              ← empty array doc kept (field absent)
// { _id:3, tags:null }   ← null doc kept
// { _id:4 }              ← missing field doc kept
WARN
When used after $lookup, always use preserveNullAndEmptyArrays: true. Without it, documents with no foreign matches (empty as array) are dropped — silently converting your LEFT JOIN into an INNER JOIN.
05
includeArrayIndex
Capture original element position after unwinding
position

After unwinding, the original position of each element within its parent array is lost. Use includeArrayIndex to store the zero-based index as a new field on each output document.

// Input: { _id:1, steps: ["Start", "Process", "End"] }

db.workflows.aggregate([{
  $unwind: {
    path: "$steps",
    includeArrayIndex: "stepIndex"
  }
}])

// Output:
// { _id:1, steps:"Start",   stepIndex: NumberLong(0) }
// { _id:1, steps:"Process", stepIndex: NumberLong(1) }
// { _id:1, steps:"End",     stepIndex: NumberLong(2) }

// Useful for: displaying step number, filtering by position, sorting after regroup

Filter by Position Using includeArrayIndex

// Retrieve only the first element of each document's array
db.col.aggregate([
  {
    $unwind: {
      path: "$items",
      includeArrayIndex: "pos"
    }
  },
  { $match: { pos: 0 } },              // keep only index 0 (first elements)
  { $project: { items: 1, pos: 0 } }  // hide the pos field in output
])
NOTE
includeArrayIndex generates NumberLong (Int64) values. With preserveNullAndEmptyArrays: true, documents with null/missing arrays receive null for the index field.
06
Unwind-Rewind Pattern
Expand → filter/transform → $group + $push to rebuild array
pattern

The unwind-rewind pattern uses $unwind to access individual elements, filters or transforms them, then uses $group with $push to reassemble a modified array.

// Remove "deprecated" tag from all articles' tags arrays
db.articles.aggregate([

  // Step 1: Expand — one doc per tag
  { $unwind: "$tags" },

  // Step 2: Filter — remove unwanted elements
  { $match: { tags: { $ne: "deprecated" } } },

  // Step 3: Rewind — rebuild the array from survivors
  //         Use $first to recover original scalar fields
  {
    $group: {
      _id:    "$_id",
      tags:   { $push: "$tags" },
      title:  { $first: "$title" },
      status: { $first: "$status" }
    }
  }
])

Transform Elements During Unwind-Rewind

// Uppercase all tags and rebuild
db.articles.aggregate([
  { $unwind: "$tags" },
  { $addFields: { tags: { $toUpper: "$tags" } } },  // transform each element
  {
    $group: {
      _id:   "$_id",
      tags:  { $push: "$tags" },
      title: { $first: "$title" }
    }
  }
])

Count Per Array Element (Tag Frequency)

// Count how many published articles have each tag
db.articles.aggregate([
  { $match: { status: "published" } },
  { $unwind: "$tags" },
  {
    $group: {
      _id:   "$tags",
      count: { $sum: 1 }
    }
  },
  { $sort: { count: -1 } },
  { $limit: 10 }
])
TIP
After $group in the rewind step, only the _id and accumulated fields survive. Always use $first/$last to recover scalar fields from the original document — they won't be available otherwise.
07
Common Patterns
Quick reference for $unwind use cases
reference
Use CasePipeline Shape
Count docs per array element$unwind → $group(_id:"$elem", count:{$sum:1}) → $sort
Filter array elements$unwind → $match → $group($push) + $first for scalars
Flatten $lookup result (1-to-1)$lookup → $unwind(preserveNullAndEmptyArrays:true)
Get element at position N$unwind(includeArrayIndex:"pos") → $match({pos:N})
Transform array elements$unwind → $addFields(compute) → $group($push)

Default vs preserveNullAndEmptyArrays Behavior

Input ArrayDefault (false)preserveNullAndEmptyArrays: true
["a","b","c"]3 documents3 documents
[] (empty)dropped1 doc, array field absent
nulldropped1 doc, field = null
field missingdropped1 doc, field absent
scalar value1 doc (treated as [value])1 doc — same