Filter concepts by levelShowing all levels.

MongoDB · Section 19

$lookup and Join-Like Operations

Level
intermediate
Read
25 min
Concepts
5

How $lookup joins across collections as a left outer join, the equality form versus the general pipeline form with let, what makes a $lookup fast or slow, when a join is the right tool versus when denormalizing the data instead would serve a hot-path read better, and how join-field cardinality shapes the size of the result.

MongoDB overview

What is true here

  1. $lookup always behaves as a left outer join — unmatched documents survive with an empty array field, and the joined field is always an array.
  2. localField/foreignField expresses one equality condition; the pipeline form with let expresses anything else, including ranges and multi-field conditions.
  3. The foreignField needs an index, or the join degrades toward a per-batch collection scan.
  4. Denormalize data read on a hot path; use $lookup for occasional, reporting-style joins where the read frequency does not justify duplicating data everywhere.
  5. Join cardinality determines the size of the resulting array, independent of whether the join is indexed.

What you will be able to do

  • Write a basic equality $lookup and know its output shape
  • Write a pipeline-form $lookup with let for a non-equality join condition
  • Identify a missing index as the cause of a slow $lookup
  • Decide between $lookup and denormalized fields for a given read pattern
  • Reason about how join cardinality affects a $lookup's output size

Writing a $lookup

The basic left-outer-join shape, and the pipeline form for anything beyond a single equality condition.

$lookup for collection joins

coreintermediate

$lookup is an aggregation stage that joins documents from another collection into the current one, attaching the matches as a new array field. It is how MongoDB reaches across collections when embedding was not chosen for that relationship.

Think of it as

It plays the same role a SQL LEFT OUTER JOIN plays — for each input document, find the matching documents in another collection and attach them — but the result always lands as an array field on the input document rather than flattened rows, because MongoDB's document model has no row-level join output shape.

text
{ $lookup: { from, localField, foreignField, as } }

What we're doing: Attach each order's customer document as a single-element array field.

basic-lookup.jsjavascript
db.orders.aggregate([
  { $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
  } }
])
2
from names the foreign collection; localField/foreignField say which fields must be equal for a match.
6
as names the new array field the matches land in — "customer" here, even though at most one match exists per order.

Why this works: $lookup always produces an array on as, whether zero, one, or many documents matched — that is the uniform output shape a pipeline-based join needs, since the number of matches is not known ahead of time.

Expecting customer to be a single object because there is only ever one matching customer

Wrong

text
// order.customer.name  — treating "customer" as a single joined document

Better

text
// order.customer[0].name, or $unwind: "$customer" first if exactly one match is expected

What you see: Code reading .name directly off the joined field gets undefined, because the field is an array even when it holds exactly one element.

Why: $lookup's output is always an array field, regardless of how many documents matched — one-to-one relationships still need either an array index or an $unwind to reach the single joined document directly.

$lookup attaches matches as an array, always a left outer join
localFieldforeignFieldattachesas array

orders

local collection

$lookup

match customerId = _id

customers

foreign collection

order + customer[]

array field, even if 0 or 1 match

  • orders — local collection
    • leads to $lookup (localField)
  • $lookup — match customerId = _id
    • leads to order + customer[] (attaches as array)
  • customers — foreign collection
    • leads to $lookup (foreignField)
  • order + customer[] — array field, even if 0 or 1 match

Remember: $lookup is a left outer join across collections — every input document survives, matches always land as an array field, and an unmatched document gets an empty array rather than being dropped.

See also: equality and pipeline lookup · lookup performance implications

Equality joins vs. pipeline $lookup

standardintermediate

The localField/foreignField form of $lookup can only express "these two fields are equal". The pipeline form (using let and a nested pipeline) can express any condition — ranges, multiple fields, or additional filtering — at the cost of more to write.

Think of it as

localField/foreignField is a shortcut for the single most common join shape; the pipeline form is the general case underneath it, the same way a simple $eq shortcut sits on top of a more general $expr.

text
{ $lookup: { from, let, pipeline: [{ $match: { $expr: {...} } }], as } }

What we're doing: Join orders to shipments where the shipment date falls within the order's delivery window — not expressible as a simple equality.

javascript
db.orders.aggregate([
  { $lookup: {
      from: "shipments",
      let: { orderId: "$_id", windowEnd: "$deliverBy" },
      pipeline: [
        { $match: { $expr: {
            $and: [
              { $eq: ["$orderId", "$$orderId"] },
              { $lte: ["$shippedAt", "$$windowEnd"] }
            ]
        } } }
      ],
      as: "onTimeShipment"
  } }
])
4
let exposes two fields from the local (orders) document as $$orderId and $$windowEnd, for the nested pipeline to reference.
6
The nested pipeline's $match combines an equality condition with a range condition — something localField/foreignField alone cannot express.

Why this works: The relationship being expressed is not "shipment.orderId equals order._id" alone, but that plus a date range — once a join needs more than one equality condition, the pipeline form is the only one that can say it.

Remember: Use localField/foreignField for a plain equality join — reach for the pipeline form with let only when the join needs a range, multiple conditions, or filtering the foreign side before it is attached.

See also: lookup for collection joins · evaluation operators and regex

Advertisement

Cost and the design choice

What makes a $lookup fast or slow, cardinality's effect on the result, and when denormalizing is the better call.

$lookup performance implications

standardintermediate

$lookup does one query against the foreign collection per batch of input documents, so it is only fast when the foreign field is indexed — without an index it is a collection scan run repeatedly, once per batch, instead of once.

Think of it as

A $lookup is a query inside a loop, from the server's point of view — every batch of local documents triggers a lookup query against the foreign collection, so the foreign field needs exactly the kind of index a repeated query needs, or the cost compounds with the number of local documents.

text
// Always index the foreignField — the field $lookup matches against on the "from" collection

What we're doing: Show the concrete fix for a slow $lookup: an index on the field being joined against.

javascript
// Slow: foreignField "customerId" on the shipments collection has no index
db.orders.aggregate([
  { $lookup: { from: "shipments", localField: "_id", foreignField: "orderId", as: "shipments" } }
])

// Fix: index the field $lookup matches on
db.shipments.createIndex({ orderId: 1 })
2
Without an index on shipments.orderId, matching each order against shipments effectively scans the shipments collection repeatedly.
6
Indexing the foreignField lets the server look up matches directly instead of scanning, the same way any other equality query benefits from an index.

Why this works: $lookup's matching step is a query against the foreign collection under the hood, so it follows the exact same rule as any other query: an equality match needs an index on the field being matched, or it degrades to scanning.

Remember: Always index foreignField before shipping a $lookup — without it, the join degrades toward a per-batch collection scan on the foreign collection, and the cost grows with input size.

See also: lookup for collection joins · indexed join fields and cardinality

$lookup vs. denormalized design

standardintermediate

$lookup is the right tool for occasional, reporting-style joins across data that genuinely belongs in separate collections. For a relationship read on every hot-path request, embedding or duplicating the needed fields is usually faster, because it avoids a join on every read.

Think of it as

MongoDB's data-modeling advice is "design for your queries" — a $lookup is what you reach for when the query pattern does not justify permanently denormalizing the data. Frequency and criticality of the read is the deciding factor, not whether a join is technically possible.

text
// Ask: is this relationship read on a hot path, and does the "many" side change independently?

What we're doing: Contrast a hot-path product listing (denormalized) against a monthly reconciliation report (a $lookup is fine there).

javascript
// Hot path: product listing shown on every page load — embed the display fields
{ _id: "sku-1", name: "Desk Lamp", price: 24.00, brandName: "Lumen" }

// Occasional: monthly report joining orders to a rarely-changing warehouse collection
db.orders.aggregate([
  { $match: { placedAt: { $gte: monthStart, $lt: monthEnd } } },
  { $lookup: { from: "warehouses", localField: "warehouseId", foreignField: "_id", as: "warehouse" } }
])
2
brandName is duplicated onto every product so the listing page never has to join, even though it costs an update in more places if a brand renames.
6
The report runs once a month, not on every request, so paying a join cost here is a reasonable trade for not duplicating warehouse data everywhere.

Why this works: The same relationship (product → brand, order → warehouse) can reasonably be modeled two different ways depending on how often and how urgently it is read — there is no universally correct answer independent of the access pattern.

Choosing between $lookup and denormalization

Choosing between $lookup and denormalization
SignalFavors
Read on every hot-path requestdenormalize (embed or duplicate fields)
Occasional report or admin view$lookup
Referenced data changes often and independently$lookup (avoids updating many duplicates)
Only a few display fields are ever neededextended reference pattern — embed those, $lookup the rest on demand

Together

javascript
// Extended reference: embed just what the order list displays
{ _id: 1, customer: { _id: 501, name: "A. Rivera" }, total: 42.00 }
// Full customer record fetched only when a user opens the order detail view

Remember: Denormalize what a hot-path read needs every time; reach for $lookup for occasional, reporting-style joins where the read frequency does not justify duplicating the data everywhere.

See also: lookup for collection joins · denormalization is intentional · workload driven design

Join field cardinality

referenceintermediate

Cardinality — how many foreign documents match one local document — shapes both the index needed and the size of the result. A high-cardinality join (one order matching thousands of log entries) produces large arrays and deserves a $limit or a narrower condition; a low-cardinality join (one order, one customer) does not.

Remember: An indexed equality join is fast regardless of cardinality — the risk is downstream: high cardinality produces large joined arrays, which is a document-size and memory concern, not just an index concern.

See also: lookup performance implications · array cardinality and index size

Advertisement