Facebook Pixel

Mongoose Relationships: ref and populate Explained

MongoDB is NoSQL but you still need relationships. Here is how ref and populate work.

Mongoose Relationships: ref and populate Explained

MongoDB is NoSQL, but real apps have relationships. Mongoose handles them with ref and populate.

The ref Field

A field can reference another collection:

const messageSchema = new mongoose.Schema({
  senderId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
  text: { type: String, required: true }
});

The field stores an ObjectId. The ref tells Mongoose which model to use when populating.

Populate

populate joins the reference on read:

const messages = await Message.find({ connectionId })
  .populate('senderId', 'firstName lastName photoUrl')
  .sort({ createdAt: 1 });

Returns messages with senderId replaced by the user object (only firstName, lastName, photoUrl).

Populate Multiple Fields

const connection = await Connection.findById(id)
  .populate('user1Id', 'firstName photoUrl')
  .populate('user2Id', 'firstName photoUrl');

Nested Populate

const posts = await Post.find()
  .populate({
    path: 'comments',
    populate: { path: 'authorId', select: 'firstName' }
  });

When to Use ref vs Embed

  • ref: for one-to-many with large N (messages, connections, payments). Keeps documents small. Allows pagination.
  • embed: for small, tightly coupled data (an address inside a user). Loads in one query.

The N+1 Problem

If you loop over messages and call User.findById for each sender, you do N+1 queries. Use populate to do it in 2 queries.

Populate Has a Cost

populate does an extra query (or more for nested). For high-traffic endpoints, denormalize: store the sender's name on the message so you do not need to populate. Trade storage for query speed.

Select Specific Fields

Always populate with a select. Do not return the entire referenced document (especially not the password hash):

.populate('senderId', 'firstName lastName photoUrl')

The Takeaway

Use ref to reference another collection and populate to join on read. Use ref for large N, embed for small tightly coupled data. Avoid the N+1 problem with populate. Always select specific fields when populating. Denormalize for high-traffic endpoints where populate is too slow.

Use a ref field that stores an ObjectId and references another model. Use populate on read to join the reference. Example: senderId: { type: ObjectId, ref: 'User' }, then Message.find().populate('senderId', 'firstName').

Use ref for one-to-many with large N (messages, connections, payments). Use embed for small, tightly coupled data (an address inside a user). Embedding large arrays bloats documents; use refs instead.

Looping over documents and calling findById for each reference does N+1 queries. Use populate to do it in 2 queries (one for the parent, one for all referenced docs).

It does an extra query (or more for nested populate). For high-traffic endpoints, denormalize: store the sender's name on the message so you do not need to populate. Trade storage for query speed.

Yes. Always populate with a select: .populate('senderId', 'firstName lastName photoUrl'). Never return the entire referenced document, especially not sensitive fields like passwordHash.

Ready to master Node.js completely?

Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course to dive deeper with high-quality video tutorials, solve interview questions, and a premium community.

Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.
Please Login.