References between collections are stored as ids, and Mongoose can resolve them into documents with populate(). On top of that it offers virtuals for computed fields, virtual populate for reverse relations, and three ways to attach reusable logic to a model. After this lesson you will be able to populate references with selection and filtering, define virtuals that serialise correctly, write instance methods, statics and query helpers, and use lean() when speed matters more than features.
Declare a reference with type: Schema.Types.ObjectId and ref naming the target model. populate() then replaces the id with the document, using a separate query per populated path (not $lookup):
const post = await Post.findById(id)
.populate("author", "name email") // select only two fields
.populate({ path: "comments", match: { approved: true }, options: { sort: { createdAt: -1 }, limit: 5 } })
.populate({ path: "author", populate: { path: "company", select: "name" } }); // nestedRules: a reference whose target no longer exists becomes null; arrays of refs populate into arrays; populating a path that is not in the schema throws unless strictPopulate: false. Populate only what the response needs — each path is an extra round trip.
When the child holds the reference (comment.post), the parent has no array to populate. A virtual populate defines the relation from the parent side without storing anything:
postSchema.virtual("comments", {
ref: "Comment",
localField: "_id", // post._id ...
foreignField: "post", // ... matches comment.post
options: { sort: { createdAt: -1 } }
});
postSchema.virtual("commentCount", { ref: "Comment", localField: "_id", foreignField: "post", count: true });
const post = await Post.findById(id).populate("comments").populate("commentCount");This follows the child-references-parent pattern from the data-modeling lessons: the parent stays small while the one-to-many side can grow.
A virtual is a getter (and optionally a setter) that is not persisted and cannot be queried:
userSchema.virtual("fullName")
.get(function () { return `${this.first} ${this.last}`; })
.set(function (v) { [this.first, this.last] = v.split(" "); });
userSchema.set("toJSON", {
virtuals: true,
transform: (doc, ret) => { delete ret.passwordHash; delete ret.__v; return ret; }
});Virtuals are omitted from toJSON() and toObject() unless virtuals: true is set, which is the most common reason a virtual "does not appear" in an API response. The transform function is the standard place to strip sensitive or internal fields before serialisation.
| Kind | Defined on | this | Called as |
|---|---|---|---|
| Instance method | schema.methods.x | a document | user.checkPassword(pw) |
| Static | schema.statics.x | the Model | User.findByEmail(email) |
| Query helper | schema.query.x | the Query | User.find().active() |
userSchema.methods.checkPassword = function (plain) { return verify(plain, this.passwordHash); };
userSchema.statics.findByEmail = function (email) { return this.findOne({ email: email.toLowerCase() }); };
userSchema.query.active = function () { return this.where({ deletedAt: null }); };
const user = await User.findByEmail("ada@example.com").active().select("+passwordHash");
if (user && await user.checkPassword(req.body.password)) { /* ... */ }Query helpers chain like built-in cursor methods, which keeps filters such as "active", "published" or "for tenant" in one place. Use function syntax so this is bound correctly.
lean() skips hydration and returns plain JavaScript objects: no getters, virtuals, methods, change tracking or save(). For list endpoints and reports it is several times faster and uses far less memory:
const rows = await Post.find({ tags: "mongodb" }).select("title createdAt").lean();Populate still works with lean(), but virtuals do not. Read with lean(), load a full document only when you intend to modify it.
toJSON: { virtuals: true }, so virtuals and virtual populates vanish from responses.this.find({ fullName: ... })); virtuals exist only in memory.How does `populate()` fetch referenced documents?
ref plus populate() resolves stored ids into documents, with select, match, options and nested populate for control.count: true returns only a number.virtuals: true in toJSON and use transform to hide sensitive data.lean() for read-only paths and full documents only when you plan to save changes.Next lesson: MongoDB with Express and Next.js — wire a shared connection into an Express API and Next.js route handlers and server components.