Skip to content

Unwind arrays

Arrays are everywhere in MongoDB documents - tags on a post, items in an order, lessons in a course. To summarise across the elements you usually need to look at them one at a time. $unwind does exactly that: it takes a document with an array field and emits one document per element, copying the rest of the document onto each.

This lesson uses its own collection, orders, instead of sales: 50 000 orders { buyer, items: [ { title, qty } ] } with 1-5 line items each (149 862 in total), plus four hand-made edge cases with _id empty, missing, null and scalar. Captured on mongo 8.3.11, mongosh 2.11.1 and Node driver 7.7.0 on one Mac; Python, Go and Rust tabs were each run once in a scratch program.

mongosh/uw-seed.js
const titles = Array.from({ length: 50 }, (_, i) => "T" + i);
const rnd = (() => { let x = 42; return () => (x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 4294967296; })(); // repeatable pseudo-random numbers
db.orders.insertMany(Array.from({ length: 50000 }, (_, i) => ({
buyer: "b" + (i % 1000),
items: Array.from({ length: 1 + Math.floor(rnd() * 5) }, () => ({ title: titles[Math.floor(rnd() * 50)], qty: 1 + Math.floor(rnd() * 3) })),
})), { ordered: false });
db.orders.insertMany([ // the awkward shapes, one of each
{ _id: "empty", buyer: "edge", items: [] },
{ _id: "missing", buyer: "edge" },
{ _id: "null", buyer: "edge", items: null },
{ _id: "scalar", buyer: "edge", items: { title: "Solo", qty: 1 } },
]);
console.log("orders:", db.orders.countDocuments(), "| line items in the array orders:", db.orders.aggregate([{ $match: { buyer: { $ne: "edge" } } }, { $group: { _id: null, n: { $sum: { $size: "$items" } } } }]).toArray()[0].n);
orders: 50004 | line items in the array orders: 149862

Give $unwind the path to an array (with the $ prefix) and it fans the document out. Every emitted document is identical except that the array field is replaced by one element. An order with two line items becomes two documents, and the 50 000 array orders become 149 862 (one per line item). The Node.js tab finishes the classic pairing: unwind, then $group to total the quantity per title:

flowchart LR
  In["one order with items array of two"] --> U["$unwind items"]
  U --> D1["order, items = Dune"]
  U --> D2["order, items = Foundation"]
A single document with a two-element array becomes two documents, one per element
mongosh/uw-basic.js
db.small.insertOne({ _id: "o1", buyer: "Ada", items: [{ title: "Dune", qty: 2 }, { title: "Foundation", qty: 1 }] });
console.log(db.small.aggregate([{ $unwind: "$items" }]).toArray());
console.log("50 000 array orders become", db.orders.aggregate([{ $match: { buyer: { $ne: "edge" } } }, { $unwind: "$items" }, { $count: "n" }]).toArray()[0].n, "documents");
assert.strictEqual(db.small.aggregate([{ $unwind: "$items" }]).toArray().length, 2);
[
{ _id: 'o1', buyer: 'Ada', items: { title: 'Dune', qty: 2 } },
{ _id: 'o1', buyer: 'Ada', items: { title: 'Foundation', qty: 1 } }
]
50 000 array orders become 149862 documents
[ { _id: 'Dune', sold: 5 }, { _id: 'Foundation', sold: 1 } ]

$unwind is rarely the last word: the usual job is to flatten so a later $group can summarise across all elements. On the full dataset, the three best-selling titles:

mongosh/uw-group.js
const rows = db.orders.aggregate([
{ $unwind: "$items" },
{ $group: { _id: "$items.title", sold: { $sum: "$items.qty" } } },
{ $sort: { sold: -1, _id: 1 } },
{ $limit: 3 },
]).toArray();
console.log(rows);
assert.strictEqual(rows.length, 3);
[
{ _id: 'T41', sold: 6271 },
{ _id: 'T18', sold: 6215 },
{ _id: 'T19', sold: 6179 }
]

In Compass: add an $unwind stage in the Aggregations tab and point it at the array field. The preview shows the document count grow - the opposite of $match and $group.

The manual’s rule: if the path is missing or an empty array $unwind ignores the document, and if the value is not an array but is not missing, null or empty, $unwind “outputs a single document using the value as-is” ($unwind, consulted 2026-09-30). All four edge-case documents, both ways:

mongosh/uw-edge-cases.js
const ids = ["empty", "missing", "null", "scalar"];
const run = (unwind) => Object.fromEntries(ids.map((id) => [id, db.orders.aggregate([{ $match: { _id: id } }, { $unwind: unwind }]).toArray().length]));
console.log("default :", run("$items"));
console.log("preserveNullAndEmptyArrays: true:", run({ path: "$items", preserveNullAndEmptyArrays: true }));
console.log("scalar output :", JSON.stringify(db.orders.aggregate([{ $match: { _id: "scalar" } }, { $unwind: "$items" }]).toArray()[0].items));
assert.deepStrictEqual(Object.values(run("$items")), [0, 0, 0, 1]);
default : { empty: 0, missing: 0, null: 0, scalar: 1 }
preserveNullAndEmptyArrays: true: { empty: 1, missing: 1, null: 1, scalar: 1 }
scalar output : {"title":"Solo","qty":1}
itemsdefaultpreserveNullAndEmptyArrays: true
[]droppedkept, items absent
missingdroppedkept
nulldroppedkept
object / scalarkept once, as-iskept once, as-is

includeArrayIndex adds the element’s position in a new field (0-based; null for non-array input and for preserved empties):

mongosh/uw-index.js
db.small.insertOne({ _id: "o3", items: ["a", "b", "c"] });
console.log(db.small.aggregate([{ $match: { _id: "o3" } }, { $unwind: { path: "$items", includeArrayIndex: "pos" } }, { $project: { _id: 0, items: 1, pos: 1 } }]).toArray());
console.log("scalar / preserved empty:", db.orders.aggregate([{ $match: { _id: { $in: ["scalar", "empty"] } } }, { $unwind: { path: "$items", includeArrayIndex: "pos", preserveNullAndEmptyArrays: true } }, { $project: { pos: 1 } }, { $sort: { _id: 1 } }]).toArray());
[
{ items: 'a', pos: Long('0') },
{ items: 'b', pos: Long('1') },
{ items: 'c', pos: Long('2') }
]
scalar / preserved empty: [ { _id: 'empty', pos: null }, { _id: 'scalar', pos: null } ]

If the answer is per order (not per element), operate on the array directly with $sum, $map or $reduce; the stream keeps 50 000 documents instead of inflating to 149 862. All three below produced identical totals:

mongosh/uw-no-unwind.js
const time = (label, p) => { const t = Date.now(); const r = db.orders.aggregate(p).toArray(); console.log(label.padEnd(26), r.length, "rows in", Date.now() - t, "ms"); return r; };
const a = time("$unwind + $group", [{ $match: { buyer: { $ne: "edge" } } }, { $unwind: "$items" }, { $group: { _id: "$_id", units: { $sum: "$items.qty" } } }]);
const b = time("$sum over the array", [{ $match: { buyer: { $ne: "edge" } } }, { $project: { units: { $sum: "$items.qty" } } }]);
const c = time("$reduce over the array", [{ $match: { buyer: { $ne: "edge" } } }, { $project: { units: { $reduce: { input: "$items", initialValue: 0, in: { $add: ["$$value", "$$this.qty"] } } } } }]);
const m = (r) => Object.fromEntries(r.map((d) => [d._id, d.units]));
assert.deepStrictEqual(m(a), m(b)); assert.deepStrictEqual(m(b), m(c));
console.log("all three give identical per-order totals");
$unwind + $group 50000 rows in 206 ms
$sum over the array 50000 rows in 149 ms
$reduce over the array 50000 rows in 158 ms
all three give identical per-order totals

$unwind multiplies the stream, so shrink it first. The optimizer hoists a $match that does not touch the array (the last line: buyer written after $unwind still read only 50 documents through its index). A filter on an element field cannot move, but you can add a redundant pre-filter on the array that lets the collection stage discard whole orders and, with a multikey index, skip reading them:

mongosh/uw-match-order.js
const stats = (p) => { // what the collection scan read, and how many documents each later stage emitted
const e = db.orders.explain("executionStats").aggregate(p), c = e.stages ? e.stages[0].$cursor : e;
return { docsRead: c.executionStats.totalDocsExamined, emitted: (e.stages ?? []).slice(1).map((s) => Object.keys(s)[0] + " " + s.nReturned).join(", ") };
};
const late = [{ $unwind: "$items" }, { $match: { "items.title": "T7" } }, { $group: { _id: null, units: { $sum: "$items.qty" } } }];
const early = [{ $match: { "items.title": "T7" } }, { $unwind: "$items" }, { $match: { "items.title": "T7" } }, { $group: { _id: null, units: { $sum: "$items.qty" } } }];
console.log("filter on an element, after $unwind :", stats(late));
console.log("pre-filter, $unwind, filter again :", stats(early));
console.log("same answer:", JSON.stringify(db.orders.aggregate(late).toArray()), "=", JSON.stringify(db.orders.aggregate(early).toArray()));
db.orders.createIndex({ "items.title": 1 });
console.log("with a multikey index on items.title :", stats(early));
const byBuyer = [{ $unwind: "$items" }, { $match: { buyer: "b7" } }];
db.orders.createIndex({ buyer: 1 });
console.log("filter on buyer written AFTER $unwind, index on buyer:", stats(byBuyer));
assert.deepStrictEqual(db.orders.aggregate(late).toArray(), db.orders.aggregate(early).toArray());
filter on an element, after $unwind : { docsRead: 50004, emitted: '$unwind 149863, $match 2940, $group 1' }
pre-filter, $unwind, filter again : { docsRead: 50004, emitted: '$unwind 10415, $match 2940, $group 1' }
same answer: [{"_id":null,"units":5945}] = [{"_id":null,"units":5945}]
with a multikey index on items.title : { docsRead: 2864, emitted: '$unwind 10415, $match 2940, $group 1' }
filter on buyer written AFTER $unwind, index on buyer: { docsRead: 50, emitted: '$unwind 156' }
  • $unwind multiplies documents (50 000 became 149 862). Shrink the stream before it and fold it after.
  • Empty, missing and null arrays vanish silently; short counts after an unwind usually mean this - use preserveNullAndEmptyArrays.
  • After unwinding, "$items.qty" reads that element’s quantity; the array is gone.
  • Counting parents after an unwind counts elements: use $addToSet of _id or count before unwinding.

Asked for “the number of orders for a buyer”, an assistant unwinds first because the items “have to be counted anyway”:

🐛 Spot the bug

// AI comment: "count orders per buyer"
db.orders.aggregate([
{ $match: { buyer: "b7" } },
{ $unwind: "$items" },
{ $group: { _id: "$buyer", orders: { $sum: 1 } } },
]);
Show the answer

After $unwind there is one document per line item, so $sum: 1 counts items: buyer b7 has 50 orders but the pipeline says 156. The number looks plausible and nothing errors, which is why it ships. Count before unwinding (countDocuments), or collect the distinct parents with $addToSet and take the $size:

mongosh/uw-bug.js
const buggy = db.orders.aggregate([{ $match: { buyer: "b7" } }, { $unwind: "$items" }, { $group: { _id: "$buyer", orders: { $sum: 1 } } }]).toArray()[0].orders;
const right = db.orders.countDocuments({ buyer: "b7" });
const fixedGroup = db.orders.aggregate([{ $match: { buyer: "b7" } }, { $unwind: "$items" }, { $group: { _id: "$buyer", orderIds: { $addToSet: "$_id" } } }, { $project: { orders: { $size: "$orderIds" } } }]).toArray()[0].orders;
console.log({ buggyOrdersForB7: buggy, actualOrders: right, fixedWithAddToSet: fixedGroup });
assert.ok(buggy > right); assert.strictEqual(fixedGroup, right);
{ buggyOrdersForB7: 156, actualOrders: 50, fixedWithAddToSet: 50 }

$unwind changes what one “row” means: before it a row is an order, after it a row is a line item that remembers its order. Every stage after it counts line items. Fold back to the level you want with $group on the parent _id, or avoid changing the row at all by working on the array in place.

ChoiceBenefitCost
$unwind + $groupSummarise across elements; simple to readStream grows 3x (149 862 documents); 211 ms vs 146 ms
$sum / $reduce on the arrayRow count unchanged; fastestMore complex expressions
preserveNullAndEmptyArraysEmpty-array documents stay in the reportObject form; later stages must handle a missing field
Pre-filter before unwind10 415 unwound instead of 149 863Redundant condition to keep in sync
  • “$unwind keeps documents with empty arrays.” - they are dropped unless you ask.
  • “$unwind needs an array.” - a scalar/object comes out once, as-is.
  • “Counting rows after an unwind counts orders.” - it counted 156 line items for 50 orders.
  • “Per-order totals need an unwind.” - $sum over the array gave identical totals, faster.

Real-world example

The MongoDB manual’s $unwind page documents both edge rules measured here: with a missing path or empty array “$unwind, by default, ignores the input document”, and preserveNullAndEmptyArrays is the switch to keep them ($unwind, consulted 2026-09-30).

Python/Go/Rust tabs mirror the verified shell/Node behaviour; each was run once in a scratch program against the same server (the unwind-group pipeline returned the same per-title totals) but they are not executed by the course’s automated checker.

What does $unwind: "$items" do to a document with a three-element items array?
By default, what happens to a document whose array is empty, missing or null?
What does $unwind do when the field is a plain object (not an array)?
You need the total quantity per order. Which is cheapest?
After $match { buyer } then $unwind then $group by buyer with $sum: 1, why is the "orders" count wrong?
How can you cut the work of an $unwind followed by a filter on an element field?