skip to content

Why does $unwind after $lookup drop documents, and how do you keep them?

level: middleimportance: should knowfreq 52%

answer

  1. Count the output documents per array element
  2. What is the length of an empty array
  3. Left outer becomes something else
  4. One option name restores the old behaviour
  5. On kept documents, the field is not there at all

basics

~20 s

Unwinding 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 lines
javascript
db.orders.aggregate([
  { $lookup: { from: "customers", localField: "customerId",
               foreignField: "_id", as: "customer" } },
  // drops orders whose customer is missing
  { $unwind: "$customer" }
])

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context