Choosing Between $lookup, Embedding, and Multiple Queries

There is no universal winner

MongoDB offers several ways to assemble a response:

  • Embed the required fields in one document.
  • Use $lookup in an aggregation pipeline.
  • Read one collection and then issue a second targeted query from the application.
  • Maintain a read model that contains the fields needed for a high-volume endpoint.

Choose based on cardinality, response size, selectivity, indexes, update frequency, and consistency needs.

Embedding

Embedding avoids a separate join for co-accessed data, but duplicates values and can inflate documents. It is strongest for bounded data or snapshots.

$lookup

$lookup joins documents from another collection during aggregation. It can be appropriate when the result genuinely needs both collections. Filter early when possible, keep the joined result bounded, and ensure join keys are indexed where relevant.

db.orders.aggregate([
  { $match: { customerId } },
  { $sort: { createdAt: -1 } },
  { $limit: 20 },
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  },
  { $unwind: "$customer" },
  {
    $project: {
      orderNumber: 1,
      total: 1,
      createdAt: 1,
      "customer.name": 1
    }
  }
]);

Multiple application queries

Two indexed queries may be easier to cache, reuse, or paginate than one complex aggregation. But the application may see slightly different states if data changes between the reads, unless a suitable consistency strategy is used.

Read models and materialized summaries

For an endpoint that serves millions of requests and combines many changing collections, a denormalized read model may be justified. It should have an explicit refresh mechanism, lag expectations, repair process, and source-of-truth definition.

Benchmark correctly

Compare representative workloads, not tiny toy collections. Include realistic data volume, skew, index state, cache state, document size, concurrent writes, network latency, and the full application response shape. Measure latency distributions and resource consumption, not just one execution time.

Specifications & Requirements
  • $lookup:Combines matching documents from another collection within an aggregation.
  • Read model:A data representation optimized for a particular read workload.
  • Materialized summary:Persisted computed data that must be refreshed or reconciled.
Note

Do not choose embedding solely to avoid $lookup, or choose $lookup solely to avoid duplication. Benchmark the full endpoint and include the cost of keeping duplicated values consistent.

Join only the latest page of orders with customer details
db.orders.aggregate([
  { $match: { customerId: ObjectId("65a000000000000000000010") } },
  { $sort: { createdAt: -1, _id: -1 } },
  { $limit: 20 },
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  },
  { $unwind: "$customer" },
  {
    $project: {
      orderNumber: 1,
      total: 1,
      createdAt: 1,
      "customer.name": 1
    }
  }
]);
Key Takeaways
  • Embedding, $lookup, and multiple queries solve different problems.
  • Limit result cardinality and project only fields needed by the endpoint.
  • Include freshness and repair requirements when introducing read models.
Knowledge Check

What should guide the choice between embedding and `$lookup`?