Why does $unwind after $lookup drop documents, and how do you keep them?
answer
- Count the output documents per array element
- What is the length of an empty array
- Left outer becomes something else
- One option name restores the old behaviour
- On kept documents, the field is not there at all
basics
~20 sUnwinding an empty array produces no output document, so input documents that matched nothing disappear and the left outer join becomes an inner join. Pass preserveNullAndEmptyArrays: true to keep them; the unwound field is simply absent on those documents.
solid answer
~40 s`$lookup` always emits an array, so pipelines routinely follow it with `$unwind` to flatten a one-to-one join into a plain sub-document. But `$unwind` outputs one document *per array element*, and an empty array has zero elements — so every input document that matched nothing is silently discarded, converting the left outer join into an inner join. The fix is `{ $unwind: { path: "$customer", preserveNullAndEmptyArrays: true } }`, which keeps those documents; on them the path is simply missing from the output, so downstream stages must tolerate its absence. There is also an optimization worth knowing: when `$unwind` immediately follows a `$lookup` and targets that lookup's `as` field, MongoDB can coalesce the two stages, so the full joined array is never materialized as an intermediate value.
code
javascript · 6 linesdb.orders.aggregate([
{ $lookup: { from: "customers", localField: "customerId",
foreignField: "_id", as: "customer" } },
// drops orders whose customer is missing
{ $unwind: "$customer" }
])go deeper
Remember that $lookup gives you an array and that $unwind is how pipelines flatten it — and that by default unwinding throws away documents whose array is empty.
Explain the mechanics: one output document per array element, zero for an empty array, and preserveNullAndEmptyArrays as the switch that restores left-outer behaviour with the field simply absent.
Show judgment about ordering and cost — where the $match belongs, when fan-out then regroup is wasteful versus aggregating inside the sub-pipeline, and why keeping $unwind adjacent to $lookup enables coalescence.
Own the reporting-shape decision: whether flattened row-per-pair output should be produced on demand at all, or materialized once, given how often it is read and how wide the fan-out gets.
## The idiom and its trap Because `$lookup` always produces an array, a pipeline that joins a one-to-one relationship usually wants to flatten it: ```js { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } }, { $unwind: "$customer" } ``` After this, `customer` is a sub-document rather than a one-element array, and `"$customer.name"` works. That is the reason the pair is so common. The trap is what it does to unmatched documents. ## Why documents vanish `$unwind` deconstructs an array field, emitting **one output document per element**. Three input shapes produce zero output documents in its default mode: - the field holds an **empty array** — no elements, so nothing to emit, - the field is **missing**, - the field is **null**. A `$lookup` that matched nothing sets `as` to `[]`, which is exactly the first case. So an order whose customer record was deleted quietly disappears from the report. The left-outer semantics that `$lookup` carefully preserved are thrown away by the very next stage — and no error is raised, so the only symptom is a row count that is lower than expected. ## Restoring outer semantics ```js { $unwind: { path: "$customer", preserveNullAndEmptyArrays: true } } ``` With this option, documents whose path is missing, null, or an empty array are passed through. For the empty-array case, the output document simply **does not contain the field** — it is not set to `null` or `[]`. That matters downstream: a later `$match: { "customer.tier": "gold" }` will exclude those documents anyway, while `$ifNull` or `$exists` lets you handle them deliberately. If you specifically want a null placeholder, add `{ $set: { customer: { $ifNull: [ "$customer", null ] } } }` after the unwind. If all you need is to flatten a genuinely one-to-one join without any risk of dropping documents, `{ $set: { customer: { $first: "$customer" } } }` is a smaller hammer: it yields the sub-document when there was a match and `null` when there was not, with no fan-out semantics involved at all. ## Fan-out on the many side When the join is one-to-many, `$unwind` does not just flatten — it multiplies. One order with four matching line items becomes four output documents, each carrying a full copy of the order's own fields. That is intended when you want a flat, row-per-pair shape for grouping or export, but it is a real cost: the order's fields are duplicated per element, and any later `$group` has to reassemble what you just took apart. If you only need a count or a sum over the joined side, computing it inside the `$lookup` sub-pipeline is cheaper than unwinding and regrouping. ## The coalescence optimization MongoDB's pipeline optimizer recognizes the specific pattern of an `$unwind` that immediately follows a `$lookup` and operates on that lookup's `as` field. When it does, it can **coalesce** the two stages into one, so the joined documents are streamed out one at a time instead of being assembled into a full array first. This matters when a single input document matches a very large number of foreign documents: without coalescence the intermediate array has to exist as a value inside one document and is bound by the 16 MB BSON document limit; with it, that array is never built. Two practical consequences follow. First, keep the `$unwind` **immediately** after the `$lookup` — inserting a `$project` or `$addFields` between them can prevent the coalescence. Second, do not read too much into stage counts when you inspect a plan: the two stages appearing as one is the optimization working, not a missing stage. ## Ordering guidance As a rule: `$match` on the left side goes *before* the `$lookup`; a `$match` on joined fields goes *after* the `$unwind` (or, better, inside the lookup's sub-pipeline so the foreign side is filtered at the source rather than after being fetched and flattened).
- After $unwind with preserveNullAndEmptyArrays: true, what does the unwound field contain for a document that matched nothing?Nothing — the field is absent from the output document rather than set to `null` or `[]`. Downstream expressions must therefore use `$ifNull` or test `$exists` instead of comparing to null. If you want an explicit placeholder, add a `$set` with `$ifNull` after the unwind to normalize the missing field to null.
- How do you flatten a one-to-one $lookup result without any risk of losing documents?Use `{ $set: { customer: { $first: "$customer" } } }` instead of `$unwind`. It replaces the one-element array with the sub-document and yields `null` when the array was empty, so no document is ever dropped and there is no fan-out behaviour to reason about. Reserve `$unwind` for the cases where you genuinely want one output row per joined element.
- Why should the $unwind stay immediately after the $lookup?Because the optimizer can coalesce an `$unwind` that directly follows a `$lookup` and targets its `as` field, streaming joined documents instead of materializing the whole array inside one document. Slipping a `$project` or `$addFields` between them can defeat that, which matters most when a single input document matches a very large number of foreign documents and the intermediate array would strain the 16 MB document limit.
saying these in an interview costs you the question
- Claims $unwind keeps unmatched documents by default
- Says the preserved field is set to null
- Thinks $unwind on a one-to-many join costs nothing
- Uses $unwind purely to flatten a one-to-one join
- Filters joined fields before the unwind in the outer pipeline