What do the unwind and lookup aggregation stages do, and how do you use them in Spring Data?
answer
- unwind = one doc per array element
- default unwind DROPS empty/null arrays → preserveNullAndEmptyArrays
- lookup = LEFT-outer join, matches as array
- lookup then unwind(as) to flatten
- lookup = same DB; index the foreignField
basics
~20 sunwind flattens an array field: it emits one output document per array element. lookup performs a left-outer join to another collection, adding matched documents as a new array field. In Spring: Aggregation.unwind("items") and Aggregation.lookup("from", "localField", "foreignField", "as").
solid answer
~40 s$unwind deconstructs an array field so each element becomes its own document — a doc with a 3-element items array produces 3 output docs, each with items as a single object, duplicating the parent fields. Use it before grouping to aggregate over array elements. In Spring: Aggregation.unwind("items"), or the overload unwind("items", true) to preserveNullAndEmptyArrays so docs with empty/missing arrays aren't dropped (otherwise they vanish). $lookup is a left-outer join: lookup(fromCollection, localField, foreignField, as) matches localField against foreignField in another collection and stores matches as an array under the as name. It never removes unmatched left docs (the as array is just empty). Commonly you follow lookup with unwind(as) to flatten the joined array into scalar fields. Both run server-side, so join and flatten happen in the database, not the JVM.
code
java · 20 linesimport static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
Aggregation agg = newAggregation(
// Flatten line items: one doc per item (keep docs with empty items)
unwind("items", true),
// Left-outer join items.productId -> products._id
lookup("products", "items.productId", "_id", "product"),
// product came back as an array; flatten the (one) match to scalar
unwind("product"),
// Revenue per product
group("product.name").sum("items.qty").as("unitsSold")
);
List<ProductSales> sales =
mongoTemplate.aggregate(agg, Order.class, ProductSales.class)
.getMappedResults();go deeper
Knows unwind flattens arrays and lookup joins collections at a high level.
Uses unwind+lookup together and knows lookup outputs an array to unwind.
Handles preserveNullAndEmptyArrays, left-outer semantics, and emulating inner joins; indexes the foreign field.
Trades off server-side $lookup vs embedding/denormalization, reasons about document-count explosion, 16MB limits, and correlated pipeline-lookups for complex joins.
**`$unwind`** flattens (deconstructs) an array field. If a document has `tags: ["a", "b", "c"]`, `$unwind("tags")` outputs **three** documents, identical except each has `tags` set to a single scalar (`"a"`, then `"b"`, then `"c"`). All non-array fields are duplicated across the produced documents. This is the standard way to aggregate over array contents — e.g., count orders per tag by unwinding tags then grouping by tag. Spring API: - `Aggregation.unwind("items")` → basic `$unwind`. - `Aggregation.unwind("items", true)` → sets **preserveNullAndEmptyArrays=true**. **Critical gotcha**: by default `$unwind` **drops** documents whose array field is missing, null, or empty. With `preserveNullAndEmptyArrays`, those documents survive (with the field absent/null). Forgetting this silently loses rows. - `Aggregation.unwind("items", "idx")` → also captures the array **index** into a new field (`includeArrayIndex`). **`$lookup`** performs a **left-outer join** to another collection in the same database. It matches a `localField` in the current pipeline documents against a `foreignField` in the target collection and stores **all matching foreign documents as an array** under a new field named by `as`. Because it's a left-outer join, documents with no match are **kept** — the `as` field is just an empty array. Spring API: - `Aggregation.lookup("orders", "customerId", "_id", "customerOrders")` — from="orders", localField="customerId", foreignField="_id", as="customerOrders". - Spring also supports the newer `LookupOperation.newLookup()...` builder and a pipeline-based lookup (with `let` + sub-pipeline) via `AggregationExpression`/`LookupOperation` for correlated joins and conditions beyond simple field equality. **Combining them.** A very common idiom is `lookup(...)` then `unwind(as)` to convert the single-element joined array into flat scalar fields (assuming a one-to-one relationship). If the relationship can be zero-to-one, add `unwind(as, true)` so unmatched left rows aren't dropped. **Edge cases / gotchas.** - **Default `$unwind` drops empty/missing arrays** — the number-one surprise; use `preserveNullAndEmptyArrays` when left rows matter. - `$unwind` **multiplies document count**; unwinding several arrays multiplies combinatorially, which can explode intermediate size and hit stage memory limits. - `$lookup` targets a **collection in the same database**; it cannot join across databases. It can be expensive without an index on the foreign `foreignField` — index it. - The joined `as` array counts toward the 16 MB document limit; a high-cardinality join can overflow. - `$lookup` is a **left-outer** join only; there's no built-in inner join — emulate by unwinding without preserve, which drops unmatched rows. - After `$lookup`, foreign documents are raw BSON; to map into a DTO, project the fields you need. **When to use.** Use `$unwind` to compute per-element aggregates over embedded arrays. Use `$lookup` to enrich documents with related data from another collection when embedding isn't feasible — but prefer embedding/denormalization in document design when reads dominate, since server-side joins are comparatively costly.
- A pipeline unexpectedly loses documents at the $unwind stage. Why?By default $unwind drops documents whose array field is null, missing, or empty. Use Aggregation.unwind("field", true) to set preserveNullAndEmptyArrays and keep those documents.
- Is $lookup an inner or outer join, and how do you make it behave like an inner join?It is a left-outer join — unmatched left documents are kept with an empty as-array. To emulate an inner join, follow it with unwind(as) WITHOUT preserveNullAndEmptyArrays, which drops the rows whose join produced an empty array.
saying these in an interview costs you the question
- Believing $unwind keeps documents with empty arrays by default
- Thinking $lookup can join across different databases
- Assuming $lookup is an inner join that drops unmatched rows
- Forgetting the joined result is an array that usually needs a follow-up unwind
- Ignoring the need to index the foreign field for lookup performance