DocsHub
Aggregation

$lookup

Learn how to join two collections together in MongoDB using $lookup to combine related data in aggregation pipelines.

$lookup

In SQL, when you need data from two tables at once, you use a JOIN. In MongoDB, the equivalent is $lookup. It lets you combine documents from two collections in a single aggregation pipeline.

For example — our courses collection stores a teacherName field. But what if you want the full teacher document — their email, join date, and subject — attached to each course? That is what $lookup does.


How $lookup Works

Input Collection — courses Foreign Collection — teachers Output — after $lookup match teacherIdto teachers._id match teacherIdto teachers._id { title: 'Math', teacherId: ObjectId('abc') } { title: 'Physics', teacherId: ObjectId('def') } { _id: ObjectId('abc'), name: 'Mr. Khan' } { _id: ObjectId('def'), name: 'Mr. Raza' } "{ title: 'Math', teacherId: ObjectId('abc'), teacher: [{ _id: ObjectId('abc'), name: 'Mr. Khan' } "{ title: 'Physics', teacherId: ObjectId('def'), teacher: [{ _id: ObjectId('def'), name: 'Mr. Raza' }

$lookup takes each document from the input collection, finds matching documents in a foreign collection, and attaches them as an array on the original document.


Basic Syntax

{
  $lookup: {
    from: "foreignCollection",  // collection to join with
    localField: "fieldInCurrentDoc",   // field in the current document
    foreignField: "fieldInForeignDoc", // field in the foreign collection to match against
    as: "outputArrayField"      // name of the new array field to add
  }
}

Basic Example — Join Courses with Teachers

Let's attach teacher information to each course. Our courses have a teacherName field, but let's restructure this properly first — using a teacherId that references the teacher's _id.

Update our courses to use teacher IDs:

// First get teacher IDs
const mathTeacher = db.teachers.findOne({ name: "Mr. Khan" })
const physicsTeacher = db.teachers.findOne({ name: "Mr. Raza" })
const bioTeacher = db.teachers.findOne({ name: "Ms. Fatima" })

// Update courses with teacher IDs
db.courses.updateOne(
  { code: "MATH-10" },
  { $set: { teacherId: mathTeacher._id } }
)
db.courses.updateOne(
  { code: "PHY-10" },
  { $set: { teacherId: physicsTeacher._id } }
)
db.courses.updateOne(
  { code: "BIO-11" },
  { $set: { teacherId: bioTeacher._id } }
)

Now run the $lookup:

db.courses.aggregate([
  {
    $lookup: {
      from: "teachers",        // join with teachers collection
      localField: "teacherId", // field in courses
      foreignField: "_id",     // field in teachers
      as: "teacher"            // attach results as "teacher" array
    }
  }
])

Result:

[
  {
    _id: ObjectId("..."),
    title: "Mathematics",
    code: "MATH-10",
    grade: "10th",
    teacherId: ObjectId("abc"),
    teacher: [
      {
        _id: ObjectId("abc"),
        name: "Mr. Khan",
        subject: "Math",
        email: "khan@school.com",
        active: true
      }
    ]
  },
  ...
]

The teacher field is an array — even if there is only one match. This is always the case with $lookup.


Unwinding the $lookup Result

Since $lookup always returns an array, and we usually expect just one teacher per course, we use $unwind to convert that array into a single object:

db.courses.aggregate([
  // Join with teachers
  {
    $lookup: {
      from: "teachers",
      localField: "teacherId",
      foreignField: "_id",
      as: "teacher"
    }
  },

  // Flatten the teacher array into a single object
  { $unwind: "$teacher" },

  // Show only what we need
  {
    $project: {
      _id: 0,
      title: 1,
      grade: 1,
      teacherName: "$teacher.name",
      teacherEmail: "$teacher.email",
      totalStudents: 1
    }
  }
])

Result:

[
  {
    title: "Mathematics",
    grade: "10th",
    teacherName: "Mr. Khan",
    teacherEmail: "khan@school.com",
    totalStudents: 30
  },
  {
    title: "Physics",
    grade: "10th",
    teacherName: "Mr. Raza",
    teacherEmail: "raza@school.com",
    totalStudents: 28
  },
  {
    title: "Biology",
    grade: "11th",
    teacherName: "Ms. Fatima",
    teacherEmail: "fatima@school.com",
    totalStudents: 25
  }
]

Clean, flat output — no nested arrays.

The pattern $lookup$unwind is so common it is practically a standard. Any time you do a lookup expecting one matching document per input document, always follow it with $unwind to flatten the result.


$lookup with $match — Filter After Joining

You can add a $match after $lookup to filter based on the joined data:

// Only show courses taught by active teachers
db.courses.aggregate([
  {
    $lookup: {
      from: "teachers",
      localField: "teacherId",
      foreignField: "_id",
      as: "teacher"
    }
  },
  { $unwind: "$teacher" },

  // Filter — only courses with active teachers
  { $match: { "teacher.active": true } },

  {
    $project: {
      _id: 0,
      title: 1,
      teacherName: "$teacher.name",
      totalStudents: 1
    }
  }
])

Multiple $lookups in One Pipeline

You can chain multiple $lookup stages to join more than two collections:

// Courses with full teacher and grade details
db.courses.aggregate([
  // Join with teachers
  {
    $lookup: {
      from: "teachers",
      localField: "teacherId",
      foreignField: "_id",
      as: "teacher"
    }
  },
  { $unwind: "$teacher" },

  // Join with students — find students enrolled in this course
  {
    $lookup: {
      from: "students",
      localField: "code",
      foreignField: "enrolledCourses",
      as: "enrolledStudents"
    }
  },

  // Project clean output
  {
    $project: {
      _id: 0,
      title: 1,
      grade: 1,
      teacherName: "$teacher.name",
      enrolledCount: { $size: "$enrolledStudents" }
    }
  }
])

Advanced $lookup with Pipeline

MongoDB also supports a more powerful $lookup syntax that lets you run a full pipeline on the foreign collection — with filtering, projection, and more:

{
  $lookup: {
    from: "foreignCollection",
    let: { localVar: "$localField" },  // define variables from local doc
    pipeline: [
      { $match: { $expr: { $eq: ["$foreignField", "$$localVar"] } } },
      { $project: { fieldToInclude: 1 } }
    ],
    as: "outputField"
  }
}

Example — Join courses with only active teachers

db.courses.aggregate([
  {
    $lookup: {
      from: "teachers",
      let: { tid: "$teacherId" },
      pipeline: [
        {
          $match: {
            $expr: {
              $and: [
                { $eq: ["$_id", "$$tid"] },
                { $eq: ["$active", true] }
              ]
            }
          }
        },
        { $project: { name: 1, email: 1, subject: 1 } }
      ],
      as: "teacher"
    }
  },
  { $unwind: "$teacher" },
  {
    $project: {
      _id: 0,
      title: 1,
      teacherName: "$teacher.name",
      teacherEmail: "$teacher.email"
    }
  }
])

The let keyword defines local variables from the current document. Inside the pipeline, reference them with $$variableName (double dollar sign).

Use the pipeline syntax when you need to filter, sort, or project the joined collection's documents before attaching them. It is more powerful than the basic localField/foreignField syntax and gives you full control over what gets attached.


When $lookup Returns No Matches

If no documents in the foreign collection match, $lookup attaches an empty array. The document is still included in the results — it just has an empty array for the joined field.

// Course with no matching teacher
{
  title: "Art",
  code: "ART-09",
  teacherId: ObjectId("nonexistent"),
  teacher: []  // empty array — no teacher found
}

If you $unwind after $lookup and a document has an empty array, that document is removed from the results by default. Use preserveNullAndEmpty: true on $unwind to keep it:

{
  $unwind: {
    path: "$teacher",
    preserveNullAndEmpty: true
  }
}

$lookup vs Embedding

You might wonder — if $lookup joins collections like SQL, why not just embed the data in the first place?

Embedding$lookup
Read speedVery fast — one querySlower — join at query time
Data consistencyManual — update in multiple placesAutomatic — source always up to date
Use whenData does not change oftenData changes frequently
ExampleStudent addressTeacher details on a course

A teacher's details — name, email, active status — can change. If you embed teacher data in every course, you have to update every course when the teacher changes. With $lookup, teacher data lives in one place and is always fresh.


School System Examples

// All courses with their teacher details
db.courses.aggregate([
  {
    $lookup: {
      from: "teachers",
      localField: "teacherId",
      foreignField: "_id",
      as: "teacher"
    }
  },
  { $unwind: "$teacher" },
  {
    $project: {
      _id: 0,
      title: 1,
      grade: 1,
      code: 1,
      teacherName: "$teacher.name",
      teacherEmail: "$teacher.email",
      totalStudents: 1
    }
  }
])

// Courses taught by active teachers only
db.courses.aggregate([
  {
    $lookup: {
      from: "teachers",
      localField: "teacherId",
      foreignField: "_id",
      as: "teacher"
    }
  },
  { $unwind: "$teacher" },
  { $match: { "teacher.active": true } },
  { $project: { _id: 0, title: 1, teacherName: "$teacher.name" } }
])

// Count courses per teacher
db.courses.aggregate([
  {
    $lookup: {
      from: "teachers",
      localField: "teacherId",
      foreignField: "_id",
      as: "teacher"
    }
  },
  { $unwind: "$teacher" },
  {
    $group: {
      _id: "$teacher.name",
      courseCount: { $sum: 1 },
      totalStudents: { $sum: "$totalStudents" }
    }
  },
  { $sort: { courseCount: -1 } },
  {
    $project: {
      _id: 0,
      teacher: "$_id",
      courseCount: 1,
      totalStudents: 1
    }
  }
])

// Students with their enrolled course details
db.students.aggregate([
  { $match: { enrolled: true } },
  {
    $lookup: {
      from: "courses",
      localField: "enrolledCourses",
      foreignField: "code",
      as: "courseDetails"
    }
  },
  {
    $project: {
      _id: 0,
      name: 1,
      grade: 1,
      courseCount: { $size: "$courseDetails" },
      courses: "$courseDetails.title"
    }
  }
])

Quick Reference

SyntaxWhat it does
Basic $lookupJoin on matching field values
$lookup + $unwindJoin and flatten single-match results
$lookup + $matchJoin then filter on joined data
Pipeline $lookupJoin with full pipeline — filter, project the foreign collection

Always add an index on the foreignField you are joining on. In our example, teachers._id is already indexed. But if you join on any other field — like courses.code — make sure that field has an index. Without it, every $lookup does a full collection scan on the foreign collection for each input document, which gets very slow very fast.

On this page