$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
$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 speed | Very fast — one query | Slower — join at query time |
| Data consistency | Manual — update in multiple places | Automatic — source always up to date |
| Use when | Data does not change often | Data changes frequently |
| Example | Student address | Teacher 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
| Syntax | What it does |
|---|---|
Basic $lookup | Join on matching field values |
$lookup + $unwind | Join and flatten single-match results |
$lookup + $match | Join then filter on joined data |
Pipeline $lookup | Join 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.