DocsHub
Mongoose

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

Course.findOne({ code: 'MATH-10' }).populate('teacherId') Step 1Fetch course documentteacherId: ObjectId('abc') Step 2Mongoose runs a second queryTeacher.findOne({ _id: ObjectId('abc') }) Step 3Replace teacherIdwith full teacher document Result{ title: 'Mathematics', teacherId: { name: 'Mr. Khan', ... }}

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 data

populate() vs $lookup

populate()$lookup
Where it runsApplication layer (Mongoose)Database layer (MongoDB)
Number of queriesOne per populationSingle query
Ease of useVery easy — one method callMore verbose pipeline syntax
Performance on large datasetsSlower — N+1 query riskFaster — single round trip
Best forSimple lookups, one or few documentsAggregations, 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

SyntaxWhat 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.

On this page