MongoDB lookup Stage

The $lookup stage performs a left outer join, attaching matching documents from another collection onto each document in the pipeline.

The Basic $lookup Shape

$lookup takes four required fields in its simplest form: from names the collection to join against, localField names the field in the current pipeline's documents, foreignField names the matching field in the other collection, and as names the new array field where matched documents will be attached. Every document from the input keeps its original fields and gains one new array field, even if no match was found, in which case that array is simply empty.

Joining Orders to Customers

db.orders.aggregate([
  { $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customerInfo"
  } }
])

After this stage runs, each order document has a new customerInfo field containing an array. In this example it will typically hold zero or one matching customer document, since customerId usually maps one-to-one against a customer's _id.

Unwrapping the Joined Array

Because $lookup always produces an array, it's common to follow it with $unwind to flatten a single matching document out of that array, or with $project to pull one field out of position zero directly. Choose $unwind when you expect zero-or-many matches and want one output document per match; choose direct array indexing when you know the join is one-to-one.

Unwinding a One-to-One Join

db.orders.aggregate([
  { $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customerInfo"
  } },
  { $unwind: "$customerInfo" },
  { $project: {
      _id: 0,
      orderId: "$_id",
      customerName: "$customerInfo.name",
      total: 1
  } }
])

Advanced $lookup with a Sub-Pipeline

A more powerful form of $lookup accepts a let clause to bring pipeline variables into scope, plus a pipeline array to run a full sub-aggregation against the foreign collection. This lets you filter, sort, or limit the joined documents rather than pulling in every match unconditionally.

Joining Only Recent Reviews per Product

db.products.aggregate([
  { $lookup: {
      from: "reviews",
      let: { productId: "$_id" },
      pipeline: [
        { $match: { $expr: { $eq: ["$productId", "$$productId"] } } },
        { $sort: { createdAt: -1 } },
        { $limit: 3 }
      ],
      as: "recentReviews"
  } }
])
FormWhen to Use
localField / foreignFieldSimple equality join between two fields
let + pipelineFiltering, sorting, or limiting matched documents before they attach
$lookup + $unwindOne-to-one joins where you want a flat, single-object result
Note: $lookup can be expensive on large collections, especially without an index on foreignField. Always index the field you are joining against on the foreign collection.
Note: $lookup always performs a left outer join: documents with no match still pass through, just with an empty array in the `as` field, so downstream stages should account for that possibility.