Filter concepts by levelShowing all levels.

MongoDB · Section 18

$unwind and Array Processing

Level
intermediate
Read
20 min
Concepts
4

How $unwind turns one document's array field into one pipeline document per element, the preserveNullAndEmptyArrays and includeArrayIndex options that control edge cases, the document-explosion cost every later stage inherits, and when $filter/$map avoid the explosion entirely because the result should stay one document per input.

MongoDB overview

What is true here

  1. $unwind multiplies document count by array length — an N-element array produces N output documents.
  2. A missing or empty array is dropped by default; preserveNullAndEmptyArrays: true keeps it as one document with a null value.
  3. includeArrayIndex records each output document's original position in the array.
  4. Filter down to needed documents before $unwind, since the multiplier applies to every stage that follows.
  5. $filter/$map keep the document count unchanged — use them whenever the result should stay one document per input.

What you will be able to do

  • Predict how many documents a given $unwind produces
  • Use preserveNullAndEmptyArrays and includeArrayIndex correctly
  • Order a pipeline to filter before unwinding, not after
  • Recognize an unwind-then-regroup pattern and replace it with $filter/$map

How $unwind works

The core transform and the two options that control its edge cases.

$unwind explodes arrays into documents

coreintermediate

$unwind takes a document with an array field and outputs one copy of that document per array element, with the array field replaced by a single element each time. A document with a 5-element array becomes 5 pipeline documents.

Think of it as

Think of $unwind as flattening a one-to-many relationship that was embedded as an array back into rows — the same shape a SQL join produces, computed on the fly instead of stored that way. It exists so later stages ($match, $group) can operate per array element instead of per whole document.

text
{ $unwind: "$field" } or { $unwind: { path: "$field", preserveNullAndEmptyArrays, includeArrayIndex } }

What we're doing: Count how many orders mention each tag, which requires per-tag documents before grouping.

unwind-then-group.jsjavascript
db.orders.aggregate([
  { $unwind: "$tags" },
  { $group: { _id: "$tags", orderCount: { $sum: 1 } } },
  { $sort: { orderCount: -1 } }
])
2
An order with tags: ["sale", "gift"] becomes two pipeline documents, one per tag — the order's other fields are duplicated onto both.
3
$group can now treat "$tags" as one scalar value per document, which is what makes counting orders per tag possible.

Why this works: $group needs one value per document to group by — a document still holding an array of tags cannot be grouped "by tag" directly, so $unwind is the step that turns "one order, many tags" into "one document per (order, tag) pair" first.

Running $unwind before a $match that only needs the parent document's other fields

Wrong

text
// $unwind: "$tags"  →  $match: { status: "active" }  — status has nothing to do with tags

Better

text
// $match: { status: "active" }  →  $unwind: "$tags"  — filter parent documents first, unwind only the survivors

What you see: The pipeline unwinds far more documents than necessary, multiplying work that a cheap early filter would have avoided.

Why: $unwind multiplies the document count by each array's length before any later stage runs — filtering on a field the unwind itself does not touch should happen first, exactly like pushing $match early in any pipeline.

One document with a 3-element array becomes three

Input

{ _id: 1, tags: ["a", "b", "c"] } — one document

$unwind: "$tags"

the array field is exploded

Output

{ _id: 1, tags: "a" }, { _id: 1, tags: "b" }, { _id: 1, tags: "c" } — three documents

  1. Input — { _id: 1, tags: ["a", "b", "c"] } — one document
  2. $unwind: "$tags" — the array field is exploded
  3. Output — { _id: 1, tags: "a" }, { _id: 1, tags: "b" }, { _id: 1, tags: "c" } — three documents

Remember: $unwind turns one document with an N-element array into N documents, each holding one element — and silently drops documents whose array is empty or missing, unless preserveNullAndEmptyArrays is set.

See also: preserve null and array index · explosion tradeoff · map filter reduce

preserveNullAndEmptyArrays and includeArrayIndex

standardintermediate

These are two options of the object form of $unwind. preserveNullAndEmptyArrays: true keeps a document even when its array is missing or empty, instead of dropping it. includeArrayIndex: "<name>" adds a field recording each output document's original position in the array.

Think of it as

The plain string form { $unwind: "$field" } is the common case; the object form exists for the two situations that case handles badly — documents you cannot afford to lose, and needing to know which position an element came from.

text
{ $unwind: { path: "$field", preserveNullAndEmptyArrays: true, includeArrayIndex: "fieldIdx" } }

What we're doing: Keep every survey response in a report, including ones with no answers array, and record each answer's original position.

javascript
db.responses.aggregate([
  { $unwind: {
      path: "$answers",
      preserveNullAndEmptyArrays: true,
      includeArrayIndex: "answerIndex"
  } }
])
2
A response whose answers array is empty or missing still produces one output document, with answers: null and answerIndex: null.
4
includeArrayIndex records the zero-based position each answer held in the original array, which the array itself no longer shows once unwound.

Why this works: A completion report that silently drops every response with zero answers would undercount non-respondents — preserveNullAndEmptyArrays keeps them visible as one row with a null value instead of vanishing.

Remember: preserveNullAndEmptyArrays keeps documents that $unwind would otherwise drop; includeArrayIndex records each element's original position — both live only in the object form of $unwind.

See also: unwind explodes arrays

Advertisement

Cost and alternatives

What the explosion actually costs downstream, and when $filter/$map are the better tool.

The document-explosion trade-off

standardintermediate

$unwind makes per-element logic easy to write, at the cost of multiplying the document count by array length at every downstream stage — a collection of 1 million documents averaging 20 array elements each becomes 20 million pipeline documents to process.

Think of it as

Every stage after $unwind pays for the multiplication, not just $unwind itself — a $sort or $match placed after it processes the exploded volume, which is why where you put $unwind in the pipeline matters as much as whether you use it.

text
// Push any $match that does not depend on the unwound field to before the $unwind stage

What we're doing: Show the same report written two ways, with and without pushing the filter before the explosion.

javascript
// Unnecessarily expensive: unwinds every order before filtering
db.orders.aggregate([
  { $unwind: "$items" },
  { $match: { status: "active" } },
  { $group: { _id: "$items.sku", n: { $sum: 1 } } }
])

// Cheaper: filters orders down first, unwinds only the survivors
db.orders.aggregate([
  { $match: { status: "active" } },
  { $unwind: "$items" },
  { $group: { _id: "$items.sku", n: { $sum: 1 } } }
])
3
This version unwinds every order's items array, including inactive orders that the very next stage is going to discard anyway.
11
Filtering to active orders first means $unwind only has to explode the documents that will actually contribute to the result.

Why this works: status does not depend on the items array at all, so there is no reason to pay the explosion cost for documents the pipeline is about to throw away — the same push-match-early principle that applies to every other stage ordering decision.

Remember: The explosion multiplier applies to every stage after $unwind, not just $unwind itself — filter down to the documents you need first, and skip $unwind entirely when a scalar result is all you actually want.

See also: unwind explodes arrays · push match early

$filter/$map instead of full $unwind

standardintermediate

When the goal is still "one document per input document" — just with a transformed or narrowed array field — reach for $filter or $map instead of $unwind. Only use $unwind when the goal genuinely is one output document per array element.

Think of it as

Ask what shape the result needs to be first. "One row per (order, item) pair" needs $unwind. "Each order, but its items array narrowed to only the shipped ones" needs $filter. Picking $unwind for the second case does the explosion and then a $group just to undo it.

text
$filter: { input, as?, cond, limit? } · $map: { input, as?, in }

What we're doing: Recognize an unwind-then-regroup pattern and replace it with an in-place $filter.

javascript
// Before: unwind, match, group back to the original document shape
db.orders.aggregate([
  { $unwind: "$items" },
  { $match: { "items.status": "shipped" } },
  { $group: { _id: "$_id", items: { $push: "$items" } } }
])

// After: filter the array in place, one stage, no document-count change
db.orders.aggregate([
  { $set: { items: { $filter: {
      input: "$items",
      cond: { $eq: ["$$this.status", "shipped"] }
  } } } }
])
5
The $group at the end exists only to undo the explosion $unwind caused two stages earlier — a sign the round trip was unnecessary.
10
$filter reaches the same narrowed-items result in one stage, with the document count never changing.

Why this works: Whenever a pipeline unwinds an array and then groups straight back to the original document, the unwind was not needed for the shape of the result — only for touching the array's contents, which $filter or $map does directly.

Remember: If the result is still "the same documents, with a transformed array field," use $filter/$map — reach for $unwind only when the result genuinely needs to be one document per array element.

See also: unwind explodes arrays · map filter reduce

Advertisement