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.
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 numbersdb.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: 149862One document per array element
Section titled “One document per array element”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"]
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);const orders = db.collection("orders");await orders.insertMany([ { _id: "o1", buyer: "Ada", items: [{ title: "Dune", qty: 2 }, { title: "Foundation", qty: 1 }] }, { _id: "o2", buyer: "Grace", items: [{ title: "Dune", qty: 3 }] },]);const rows = await orders.aggregate([ { $unwind: "$items" }, { $group: { _id: "$items.title", sold: { $sum: "$items.qty" } } }, { $sort: { sold: -1 } },]).toArray();console.log(rows);if (rows[0]._id !== "Dune" || rows[0].sold !== 5) throw new Error("unexpected totals");rows = list(db.orders.aggregate([ {"$unwind": "$items"}, {"$group": {"_id": "$items.title", "sold": {"$sum": "$items.qty"}}}, {"$sort": {"sold": -1}},]))pipeline := mongo.Pipeline{ {{Key: "$unwind", Value: "$items"}}, {{Key: "$group", Value: bson.D{ {Key: "_id", Value: "$items.title"}, {Key: "sold", Value: bson.D{{Key: "$sum", Value: "$items.qty"}}}, }}}, {{Key: "$sort", Value: bson.D{{Key: "sold", Value: -1}}}},}cur, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}var rows []bson.Mif err := cur.All(ctx, &rows); err != nil { return err}let mut cursor = coll .aggregate(vec![ doc! { "$unwind": "$items" }, doc! { "$group": { "_id": "$items.title", "sold": { "$sum": "$items.qty" } } }, doc! { "$sort": { "sold": -1 } }, ]) .await?;[ { _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:
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.
Empty, missing, null and scalar
Section titled “Empty, missing, null and scalar”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:
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}| items | default | preserveNullAndEmptyArrays: true |
|---|---|---|
[] | dropped | kept, items absent |
| missing | dropped | kept |
null | dropped | kept |
| object / scalar | kept once, as-is | kept once, as-is |
includeArrayIndex adds the element’s position in a new field (0-based; null for non-array input and for preserved empties):
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 } ]You may not need to unwind
Section titled “You may not need to unwind”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:
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 msall three give identical per-order totalsFilter before you unwind
Section titled “Filter before you unwind”$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:
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' }Tips and gotchas
Section titled “Tips and gotchas”$unwindmultiplies documents (50 000 became 149 862). Shrink the stream before it and fold it after.- Empty, missing and
nullarrays vanish silently; short counts after an unwind usually mean this - usepreserveNullAndEmptyArrays. - After unwinding,
"$items.qty"reads that element’s quantity; the array is gone. - Counting parents after an unwind counts elements: use
$addToSetof_idor count before unwinding.
Reading AI-generated code
Section titled “Reading AI-generated code”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:
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 }Mental Model
Section titled “Mental Model”$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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
$unwind + $group | Summarise across elements; simple to read | Stream grows 3x (149 862 documents); 211 ms vs 146 ms |
$sum / $reduce on the array | Row count unchanged; fastest | More complex expressions |
preserveNullAndEmptyArrays | Empty-array documents stay in the report | Object form; later stages must handle a missing field |
| Pre-filter before unwind | 10 415 unwound instead of 149 863 | Redundant condition to keep in sync |
Common Misconceptions
Section titled “Common Misconceptions”- “
$unwindkeeps documents with empty arrays.” - they are dropped unless you ask. - “
$unwindneeds 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.” -
$sumover the array gave identical totals, faster.
Real-world example
The MongoDB manual’s
$unwindpage documents both edge rules measured here: with a missing path or empty array “$unwind, by default, ignores the input document”, andpreserveNullAndEmptyArraysis 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.