Skip to main content

πŸ”— Population & References

Real data is connected: a post has an author, an order has a customer, a book has a publisher. MongoDB has no JOIN, so Mongoose gives you two strategies β€” store the related data inline (embedding) or store a pointer to it (referencing). This lesson is about references and the magic method that follows them: populate().

Week 8 · Day 4 (Thursday: Mongoose ODM) · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Choose between embedding and referencing for a given relationship
  • Declare references with ObjectId + ref, single and in arrays
  • Resolve references with populate() and trace how it works
  • Use selective, filtered, sorted, and nested population
  • Set up virtual populate for reverse (one-to-many) relationships
  • Apply lean() and field selection to keep populated queries fast

Estimated Time: 70 minutes

Practice: Link Users, Posts, and Comments and fetch a post with its author and comments.

In This Lesson

Embed or Reference?

Every relationship you model comes down to one decision. Do you keep the related data inside the parent document, or do you keep it in its own collection and point to it?

  • Embedding (denormalization) β€” nest the data directly. Like writing all your contact details on one business card: one lookup gets everything, but the card can only hold so much.
  • Referencing (normalization) β€” store the related document's _id. Like a library catalog card that lists a book's shelf location rather than the whole book.
Use embedding when…Use referencing when…
The data is always read with its parentThe data is queried independently
It's small and boundedIt's large or grows without limit
It belongs to exactly one parentIt's shared across many documents
Example: a product's size variantsExample: the author of many posts

πŸ’‘ Rule of thumb

"Contains" relationships that are small and load together β†’ embed. "References" relationships that are shared, large, or unbounded β†’ reference. Remember MongoDB's 16 MB document limit: an ever-growing embedded array (comments, activity log) will eventually hit it β€” reference those.

Creating References

A reference is just an ObjectId field annotated with ref, which names the model it points to. That ref is what lets populate() know which collection to look in later.

import mongoose from 'mongoose';
const { Schema } = mongoose;

const postSchema = new Schema({
  title: { type: String, required: true },
  content: String,
  author: {                                   // single reference
    type: Schema.Types.ObjectId,
    ref: 'User',                              // β†’ the 'User' model
    required: true
  },
  categories: [{                              // array of references
    type: Schema.Types.ObjectId,
    ref: 'Category'
  }]
});

const Post = mongoose.model('Post', postSchema);

When you create a document, you store only the _id of the related record β€” not the record itself:

const user = await User.create({ name: 'Ada Lovelace', email: 'ada@x.com' });

const post = await Post.create({
  title: 'On Analytical Engines',
  content: 'The engine can do whatever we know how to order it to perform...',
  author: user._id            // just the pointer
});

// Right now post.author is an ObjectId, NOT the user object:
console.log(post.author);      // new ObjectId("6650...")
A post document stores the author's ObjectId, pointing into the users collection posts _id: 456def title: "On Analytical…" author: 123abc users _id: 123abc name: "Ada Lovelace" email: "ada@x.com" ref: 'User'
The post's author field holds only the user's _id. The ref tells Mongoose which collection that id lives in.

populate(): Resolving Refs

Storing an id is efficient, but you usually want the actual author, not a cryptic ObjectId. populate() swaps the id for the full document it points to. Under the hood Mongoose runs a second query against the referenced collection and stitches the result back in β€” MongoDB itself does no join.

const post = await Post.findById(postId).populate('author');

console.log(post.author.name);   // "Ada Lovelace" β€” resolved!
/* post now looks like:
{
  _id: 456def,
  title: "On Analytical Engines",
  author: { _id: 123abc, name: "Ada Lovelace", email: "ada@x.com" }
}
*/

Here's the flow populate('author') actually performs β€” the reference-resolution loop:

sequenceDiagram participant App participant Mongoose participant Posts participant Users App->>Mongoose: Post.findById(id).populate("author") Mongoose->>Posts: query 1 β€” find the post Posts-->>Mongoose: post with author = ObjectId(123abc) Mongoose->>Users: query 2 β€” find _id in [123abc] Users-->>Mongoose: the matching user document Mongoose->>Mongoose: replace author id with the user doc Mongoose-->>App: fully populated post

You can populate several fields at once, either by chaining or with an array spec:

// Chained
const order = await Order.findById(id)
  .populate('customer')
  .populate('products');

// Or one array β€” same result, less repetition
const order2 = await Order.findById(id)
  .populate(['customer', 'products']);

⚠️ populate needs a live connection

populate() fires a real query, so it only works on a connected Mongoose model β€” you can't populate a plain object you built by hand, and the ref name must exactly match a compiled model. A wrong ref silently yields null, not an error.

Selective & Nested Populate

Passing an options object to populate() unlocks field selection, filtering, sorting, limiting, and multi-level population.

Select only the fields you need

// Second arg is a field projection β€” grab just the name
const post = await Post.findById(id).populate('author', 'name');

// Or exclude fields with a leading minus:
const post2 = await Post.findById(id).populate('author', '-password -email');

Filter, sort, and limit populated docs

const order = await Order.findById(id).populate({
  path: 'products',
  match: { inStock: true },        // only populate matching docs
  select: 'name price',            // just these fields
  options: { sort: { price: -1 }, limit: 5 }
});
// Note: a `match` that filters everything out leaves an [] (or null for single refs)

Nested (multi-level) population

Populate a reference, then populate a reference inside that β€” e.g. a student's courses, and each course's instructor:

const student = await Student.findById(id).populate({
  path: 'courses',
  populate: { path: 'instructor', select: 'name' }
});

student.courses[0].instructor.name;  // resolved two levels deep

Virtual Populate

References point one way: a post knows its author, but a user doesn't store a list of their posts. Rather than maintain an ever-growing array on the user, use a virtual populate β€” a computed relationship that queries the reverse direction on demand.

const userSchema = new Schema({ name: String, email: String }, {
  toJSON:   { virtuals: true },   // include virtuals in output
  toObject: { virtuals: true }
});

// "A user's posts" = every Post whose `author` equals this user's `_id`
userSchema.virtual('posts', {
  ref: 'Post',            // model to query
  localField: '_id',      // this document's field...
  foreignField: 'author', // ...matched against this field on Post
  justOne: false          // false β†’ an array (one-to-many)
});

const User = mongoose.model('User', userSchema);

// Now populate it like any other field:
const user = await User.findById(id).populate('posts');
console.log(user.posts);  // array of that user's posts β€” nothing stored on the user!

βœ… Why virtuals beat a stored array here

A stored posts: [ObjectId] array must be kept in sync on every post create/delete and can grow past limits. A virtual is always accurate because it's computed from the source of truth (the post's author), and it stores nothing extra. Reach for it whenever you need the "many" side of a one-to-many.

Performance

Every populate() is an extra database round-trip. It's convenient, but careless use turns one fast query into many slow ones. Keep populated queries lean:

  • Select fields β€” populate('author', 'name') transfers a fraction of the data.
  • Use lean() β€” for read-only responses, skip building full Mongoose documents and get plain objects back, much faster.
  • Index foreign keys β€” the referenced field (e.g. Post.author) should be indexed so the lookup is quick.
  • Limit depth β€” deep nested populate multiplies queries; denormalize a few hot fields instead if reads dominate.
// A tight, read-optimised query:
const posts = await Post.find({ isPublished: true })
  .select('title author publishedAt')
  .populate('author', 'name')   // only the author's name
  .sort({ publishedAt: -1 })
  .limit(20)
  .lean();                      // plain JS objects β€” no document overhead

πŸ’‘ lean() drops the extras

lean() returns raw objects with no virtuals, getters, instance methods, or change tracking. That's exactly what you want for sending JSON to a client. Skip lean() only when you need to call .save() or a document method on the result.

Full Example: Blog

Putting it together β€” Users, Categories, Posts, and Comments, wired with references, a virtual, and a single query that assembles a complete post with its author, categories, and approved comments.

import mongoose from 'mongoose';
const { Schema } = mongoose;

const userSchema = new Schema({ name: String, email: { type: String, unique: true } });

const categorySchema = new Schema({ name: String, slug: { type: String, unique: true } });

const commentSchema = new Schema({
  content: { type: String, required: true },
  author:  { type: Schema.Types.ObjectId, ref: 'User', required: true },
  post:    { type: Schema.Types.ObjectId, ref: 'Post', required: true },
  isApproved: { type: Boolean, default: true }
}, { timestamps: true });

const postSchema = new Schema({
  title:   { type: String, required: true },
  content: { type: String, required: true },
  author:     { type: Schema.Types.ObjectId, ref: 'User', required: true },
  categories: [{ type: Schema.Types.ObjectId, ref: 'Category' }],
  isPublished: { type: Boolean, default: false }
}, { timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } });

// Reverse relationship: a post's comments (nothing stored on the post)
postSchema.virtual('comments', {
  ref: 'Comment', localField: '_id', foreignField: 'post'
});

const User     = mongoose.model('User', userSchema);
const Category = mongoose.model('Category', categorySchema);
const Comment  = mongoose.model('Comment', commentSchema);
const Post     = mongoose.model('Post', postSchema);

// One query, fully assembled:
async function getFullPost(postId) {
  return Post.findById(postId)
    .populate('author', 'name email')
    .populate('categories', 'name slug')
    .populate({
      path: 'comments',
      match: { isApproved: true },
      options: { sort: { createdAt: -1 } },
      populate: { path: 'author', select: 'name' }   // comment authors too
    });
}

Practice & Quiz

πŸ‹οΈ Exercise 1: Link and populate

Goal: Given User and Post models where Post.author references User, create a user and a post by that user, then fetch the post with its author's name resolved (and nothing else).

πŸ’‘ Hint

Store user._id in the post's author. Retrieve with .populate('author', 'name') β€” the second argument selects the field.

βœ… Solution
const user = await User.create({ name: 'Grace Hopper', email: 'grace@x.com' });

const post = await Post.create({
  title: 'The First Compiler',
  content: 'A program that translates for you...',
  author: user._id
});

const full = await Post.findById(post._id).populate('author', 'name');
console.log(full.author.name);   // "Grace Hopper"

πŸ‹οΈ Exercise 2: Reverse with a virtual

Goal: Add a virtual named posts to userSchema so you can call User.findById(id).populate('posts') and get every post that user authored. Remember to enable virtuals in the schema options.

βœ… Solution
const userSchema = new Schema({ name: String, email: String }, {
  toJSON: { virtuals: true }, toObject: { virtuals: true }
});

userSchema.virtual('posts', {
  ref: 'Post',
  localField: '_id',
  foreignField: 'author'
});

// Usage:
const u = await User.findById(id).populate('posts');
console.log(u.posts.length);

🎯 Quick Quiz

Question 1: Before you call populate(), what is stored in a reference field like post.author?

Question 2: Roughly how does populate() resolve a reference?

Question 3: You need "all posts by a user" without storing an array on the user. Best tool?

Best Practices & Pitfalls

βœ… Do

  • Reference shared, large, or unbounded data; embed small, private, always-loaded data
  • Always select only the fields you need when populating
  • Add lean() to read-only queries that feed a JSON response
  • Index every foreign-key field you populate against
  • Use virtual populate for the "many" side of one-to-many relationships

❌ Don't

  • Populate deeply on hot paths without measuring β€” each level is more queries
  • Let an embedded array grow forever β€” the 16 MB document limit is real
  • Misspell a ref model name β€” you'll silently get null, not an error
  • Call document methods on a lean() result β€” there aren't any

⚠️ The N+1 populate trap

Fetching a list and populating inside a loop fires a query per item. Instead, populate the whole list in one call β€” Post.find().populate('author') β€” and Mongoose batches the referenced ids into a single follow-up query.

Summary

πŸŽ‰ Key Takeaways

  • Embed small, private, always-loaded data; reference shared, large, or unbounded data
  • A reference is an ObjectId plus a ref naming the target model
  • populate() runs an extra query and swaps the id for the full document
  • Options give you select, match, sort, limit, and nested population
  • Virtual populate models reverse one-to-many links without storing an array
  • lean() + field selection + indexes keep populated queries fast

πŸ“š Additional Resources

πŸš€ What's Next?

You can now model, validate, and connect data with confidence. As applications evolve, their data shape changes too β€” and existing documents must come along. Next: Database Migrations Concepts, where you'll learn to evolve a schema safely without losing or corrupting data.

πŸŽ‰ Relationships, connected!

Embedding vs. referencing, populate(), and virtuals are the tools behind every real MongoDB data model. You've now covered the full Mongoose core.