Population
Learn how to use Mongoose's populate() to automatically replace referenced IDs with full documents from other collections.
Population
In the Schema Design section, we learned when to reference data instead of embedding it. In our school system, a course references its teacher by teacherId, and a student references their courses by courseIds.
When you fetch a course, you get the raw teacherId — just an ObjectId. To get the actual teacher details, you would normally need a second query, or $lookup in an aggregation pipeline.
Mongoose's populate() does this for you automatically — it is the application-layer equivalent of $lookup.
Setting Up References with ref
For populate() to work, your schema must define a ref — telling Mongoose which model the ObjectId points to.
const courseSchema = new mongoose.Schema({
title: String,
code: String,
grade: String,
teacherId: {
type: mongoose.Schema.Types.ObjectId,
ref: "Teacher" // points to the Teacher model
}
});The ref value must exactly match the name you used in mongoose.model("Teacher", teacherSchema).
Basic populate()
const Course = require('./models/Course');
// Without populate — teacherId is just an ObjectId
const course = await Course.findOne({ code: "MATH-10" });
console.log(course.teacherId); // ObjectId("64a1f2c3e4b0a1b2c3d4e5f6")
// With populate — teacherId becomes the full teacher document
const courseWithTeacher = await Course
.findOne({ code: "MATH-10" })
.populate("teacherId");
console.log(courseWithTeacher.teacherId);
// {
// _id: ObjectId("64a1f2c3e4b0a1b2c3d4e5f6"),
// name: "Mr. Khan",
// subject: "Math",
// email: "khan@school.com",
// active: true
// }
console.log(courseWithTeacher.teacherId.name); // "Mr. Khan"populate("teacherId") tells Mongoose — for the teacherId field, go fetch the full document from the Teacher collection and replace the ID with it.
How populate() Works Under the Hood
Behind the scenes, populate() runs a separate query to the referenced collection and merges the result. It is two queries total — but Mongoose handles the merging for you, so it feels like one.
populate() runs an extra database query. For simple cases this is fine. But if you are populating inside a loop over many documents, consider using aggregation with $lookup instead — it is a single query regardless of how many documents you have.
Populating Multiple Fields
Chain .populate() multiple times to populate different fields:
const enrollment = await Enrollment
.findOne({ status: "active" })
.populate("studentId")
.populate("courseId");
console.log(enrollment.studentId.name); // "Ali Hassan"
console.log(enrollment.courseId.title); // "Mathematics"Populating Arrays of References
When a field is an array of ObjectIds, populate() replaces each ID with its full document:
const studentSchema = new mongoose.Schema({
name: String,
grade: String,
courseIds: [
{ type: mongoose.Schema.Types.ObjectId, ref: "Course" }
]
});const student = await Student
.findOne({ name: "Ali Hassan" })
.populate("courseIds");
console.log(student.courseIds);
// [
// { _id: ObjectId("..."), title: "Mathematics", code: "MATH-10" },
// { _id: ObjectId("..."), title: "Physics", code: "PHY-10" }
// ]
student.courseIds.forEach(course => {
console.log(course.title);
});select Inside populate
Use the object syntax to limit which fields come back from the populated document — same as .select():
const course = await Course
.findOne({ code: "MATH-10" })
.populate({
path: "teacherId",
select: "name email -_id" // only these fields, exclude _id
});
console.log(course.teacherId);
// { name: "Mr. Khan", email: "khan@school.com" }Shorthand string syntax
// Same as above, shorter
const course = await Course
.findOne({ code: "MATH-10" })
.populate("teacherId", "name email -_id");Always use select with populate() to fetch only the fields you actually need. Populating the full teacher document when you only need their name wastes bandwidth and memory — especially when populating arrays with many items.
Filtering Populated Documents with match
You can filter which documents get populated using match — useful for arrays of references:
// Only populate courses that are in 10th grade
const student = await Student
.findOne({ name: "Ali Hassan" })
.populate({
path: "courseIds",
match: { grade: "10th" }
});When using match, if a referenced document does not match the filter, Mongoose leaves null in its place in the array (for single references) or removes it from the array (for array references) — but the array length might still reflect the original count for single populates. Always check for null values when using match.
Nested Population
Sometimes you need to populate a field, and then populate a field on THAT populated document — multiple levels deep.
Example — Enrollment → Course → Teacher
const enrollment = await Enrollment
.findOne({ status: "active" })
.populate({
path: "courseId",
populate: {
path: "teacherId",
select: "name email"
}
});
console.log(enrollment.courseId.title); // "Mathematics"
console.log(enrollment.courseId.teacherId.name); // "Mr. Khan"The nested populate inside populate tells Mongoose — after populating courseId, also populate teacherId on the resulting course document.
Virtual Populate
Sometimes the relationship is the "wrong way around" for populate() to work directly. For example — our Course model does not store a list of enrolled students. Instead, the Enrollment collection stores courseId pointing back to the course.
Virtual populate lets you populate this reverse relationship without storing an array of IDs.
Setting up a virtual populate field
const courseSchema = new mongoose.Schema({
title: String,
code: String,
teacherId: { type: mongoose.Schema.Types.ObjectId, ref: "Teacher" }
}, {
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// Virtual field — not stored in the database
courseSchema.virtual('enrollments', {
ref: "Enrollment", // the model to look in
localField: "_id", // field on THIS model
foreignField: "courseId", // field on the Enrollment model
justOne: false // false = array, true = single document
});Using virtual populate
const course = await Course
.findOne({ code: "MATH-10" })
.populate("enrollments");
console.log(course.enrollments);
// [
// { studentId: ObjectId("..."), courseId: ObjectId("..."), status: "active" },
// { studentId: ObjectId("..."), courseId: ObjectId("..."), status: "active" }
// ]Combining virtual populate with nested populate
const course = await Course
.findOne({ code: "MATH-10" })
.populate({
path: "enrollments",
populate: {
path: "studentId",
select: "name grade"
}
});
course.enrollments.forEach(enrollment => {
console.log(enrollment.studentId.name); // "Ali Hassan", "Sara Ahmed", ...
});Virtual populate is ideal when you have a one-to-many relationship and the "many" side stores the reference (like Enrollment.courseId). Instead of duplicating an array of IDs on the "one" side, define a virtual that looks up the relationship on demand.
Conditionally Populate
You can build a query and decide whether to populate based on conditions:
async function getCourse(code, includeTeacher = false) {
let query = Course.findOne({ code });
if (includeTeacher) {
query = query.populate("teacherId", "name email");
}
return await query;
}
const course = await getCourse("MATH-10", true); // includes teacher
const courseOnly = await getCourse("MATH-10"); // no teacher datapopulate() vs $lookup
populate() | $lookup | |
|---|---|---|
| Where it runs | Application layer (Mongoose) | Database layer (MongoDB) |
| Number of queries | One per population | Single query |
| Ease of use | Very easy — one method call | More verbose pipeline syntax |
| Performance on large datasets | Slower — N+1 query risk | Faster — single round trip |
| Best for | Simple lookups, one or few documents | Aggregations, reports, large datasets |
// populate — simple, one document
const course = await Course.findOne({ code: "MATH-10" }).populate("teacherId");
// $lookup — aggregation, many documents, calculations
const report = await Course.aggregate([
{ $lookup: { from: "teachers", localField: "teacherId", foreignField: "_id", as: "teacher" } },
{ $unwind: "$teacher" },
{ $group: { _id: "$teacher.name", courseCount: { $sum: 1 } } }
]);Avoid calling populate() inside a loop over many documents — each call is a separate database query (the N+1 problem). If you need related data across many documents with calculations or grouping, use an aggregation pipeline with $lookup instead.
School System Examples
// Course with teacher details
const course = await Course
.findOne({ code: "MATH-10" })
.populate("teacherId", "name email subject");
// Student with their enrolled courses
const student = await Student
.findOne({ name: "Ali Hassan" })
.populate("courseIds", "title code grade");
// Enrollment with both student and course populated
const enrollment = await Enrollment
.findOne({ status: "active" })
.populate("studentId", "name grade")
.populate("courseId", "title code");
// Course with nested teacher and enrolled students (virtual populate)
const fullCourse = await Course
.findOne({ code: "MATH-10" })
.populate("teacherId", "name email")
.populate({
path: "enrollments",
populate: { path: "studentId", select: "name grade" }
});
// All courses for 10th grade with teacher names
const courses = await Course
.find({ grade: "10th" })
.populate("teacherId", "name -_id")
.select("title code teacherId");Quick Reference
| Syntax | What it does |
|---|---|
.populate("field") | Populate a referenced field with the full document |
.populate("field", "name email") | Populate with only specific fields |
.populate({ path: "field", select: "..." }) | Object syntax with field selection |
.populate({ path: "field", populate: {...} }) | Nested population |
.populate({ path: "field", match: {...} }) | Filter populated documents |
schema.virtual("field", { ref, localField, foreignField }) | Define a virtual populate field |
Always set up ref correctly when defining ObjectId fields in your schema — even if you do not need populate() immediately. It costs nothing to add and means the relationship is documented directly in your schema, making it easy to populate later when you need it.