Skip to content
Armin Shaikhy

[ ARTICLE_003 ] / MONGO_REL

Modeling Relations in MongoDB

Embedding vs referencing, when each pattern breaks down, and how to use $lookup and Mongoose populate to query across documents.

MongoDB does not have joins. That is not an oversight - it is a deliberate design choice. Documents are meant to be self-contained. But real data has relationships, and you still need to model them. The question is how.

There are two approaches: embed the related data inside the document, or store a reference and look it up separately. Most data models end up using both. Knowing when to use each is the skill.

Embedding

Embedding means storing related data directly inside a document rather than in a separate collection.

// users collection
{
  _id: ObjectId("64a1f2c3..."),
  name: "Armin",
  address: {
    street: "123 Main St",
    city: "Berlin",
    country: "DE"
  }
}

The address is embedded in the user document. There is no addresses collection. To get a user with their address, you query once and get everything back.

Use embedding when:

  • The related data is always read together with the parent. If you never query addresses without users, there is no reason to separate them.
  • The nested data is specific to one parent and will not be shared. An address belongs to one user.
  • The array is bounded and will not grow without limit. Embedding an array of 10,000 items in a single document is a problem - MongoDB documents have a 16MB limit and large arrays slow down reads and writes.
// Post with embedded comments - fine if comments are few
{
  _id: ObjectId("..."),
  title: "Getting Started with MongoDB",
  content: "...",
  comments: [
    { author: "Lucas", body: "Great post", createdAt: ISODate("...") },
    { author: "Sara", body: "Very helpful", createdAt: ISODate("...") }
  ]
}

This breaks down if a post gets 50,000 comments. A viral post will hit the document size limit and every read of the post pulls all comments regardless of whether you display them.

Referencing

Referencing means storing the _id of a related document and retrieving it in a separate query.

// posts collection
{
  _id: ObjectId("64b2a1d4..."),
  title: "Getting Started with MongoDB",
  authorId: ObjectId("64a1f2c3..."),
  content: "..."
}

// users collection
{
  _id: ObjectId("64a1f2c3..."),
  name: "Armin"
}

authorId is a manual reference. MongoDB does not enforce it - there is no foreign key constraint. If you delete the user, the authorId in posts stays there pointing at nothing. You own that integrity check.

Use referencing when:

  • The related document is large and you do not always need it.
  • The related entity is shared across many parents. A user can author hundreds of posts - storing a full copy of the user in each post is wasteful and gets out of sync when the user updates their name.
  • The array could grow unboundedly. Move comments to their own collection and reference the parent post.
  • You need to query the related entity independently. If you have pages that show all comments by a specific user, a separate comments collection with a userId field is much easier to query.

$lookup - Joining in the Aggregation Pipeline

When you need data from two collections together, $lookup is the answer. It is MongoDB’s version of a SQL join, available in the aggregation pipeline.

db.posts.aggregate([
  {
    $lookup: {
      from: "users",
      localField: "authorId",
      foreignField: "_id",
      as: "author",
    },
  },
]);

This produces posts with an author array containing the matched user documents. as: "author" is always an array even when there is one match - unwrap it with $unwind if needed.

db.posts.aggregate([
  {
    $lookup: {
      from: "users",
      localField: "authorId",
      foreignField: "_id",
      as: "author",
    },
  },
  { $unwind: "$author" },
  {
    $project: {
      title: 1,
      "author.name": 1,
      "author.email": 1,
    },
  },
]);

$lookup works well for dashboards and reporting queries. It is slower than an embedded read because it requires a join at query time. Do not reach for it on every query - if you are always loading a post with its author, consider embedding the author’s name directly in the post document (more on this below).

Mongoose populate()

If you are using Mongoose, populate() is a higher-level abstraction over manual references. It executes two queries - one for the main document, one for the referenced collection - and stitches them together.

Define the reference in your schema:

const postSchema = new mongoose.Schema({
  title: String,
  content: String,
  author: { type: mongoose.Schema.Types.ObjectId, ref: "User" },
});

Then populate at query time:

const post = await Post.findById(id).populate("author", "name email");

This fetches the post and then fetches the referenced user, returning the user’s name and email in place of the ObjectId. Clean syntax, but remember it is two round trips to the database, not one.

You can populate nested paths too:

const post = await Post.findById(id)
  .populate("author", "name")
  .populate("comments.user", "name avatar");

The cost: every populate() is an extra query. For a list of 50 posts, populate("author") runs 50 individual user lookups unless you batch carefully. Mongoose does batch them into one query per populated path, but it is still a separate round trip.

The Hybrid Pattern

Embedding and referencing are not mutually exclusive. A common pattern is to embed frequently-read summary data inside the document while maintaining a reference to the full document.

// posts collection
{
  _id: ObjectId("..."),
  title: "Modeling Relations in MongoDB",
  author: {
    _id: ObjectId("64a1f2c3..."),
    name: "Armin"        // embedded for fast reads
  }
}

Displaying a post with the author’s name requires one query and zero joins. The trade-off is that if the user changes their name, you need to update every post they authored.

This is called denormalization and it is a deliberate choice. SQL databases avoid it to prevent update anomalies. In MongoDB, you sometimes make that trade explicitly because read performance matters more than the cost of updating stale denormalized fields.

Use this pattern when:

  • The embedded field rarely changes (usernames, slugs)
  • You have many reads and few updates
  • The latency of a join or populate is measurable and matters

Practical Decision Guide

| Scenario | Pattern | | ------------------------------------- | ---------------------------------------------------------------- | | User profile with address | Embed address in user | | Post with a few comments | Embed comments in post | | Post with unbounded comments | Reference - separate comments collection | | Order with line items | Embed line items in order | | Product referenced by many orders | Reference product, embed snapshot of price/name at purchase time | | Always display post with author name | Hybrid - embed name, keep reference | | Reporting query across collections | $lookup aggregation | | Application-layer fetch with Mongoose | populate() |

What MongoDB Does Not Do

MongoDB does not enforce referential integrity. If you store authorId: ObjectId("...") and then delete that user, MongoDB will not stop you. The reference becomes a dangling pointer silently.

You also cannot do a cascading delete the way a relational database can. If a user is deleted and you want all their posts gone too, that is application logic you write yourself.

These are real limitations. For data where referential integrity is critical, consider whether a relational database is the better fit, or build the integrity checks explicitly into your application layer.

Summary

Embed when you read data together and the nested content is bounded. Reference when data is shared, large, or needs independent querying. Use $lookup or populate() to query across collections. Use the hybrid pattern when display performance matters and updates are infrequent.

Most MongoDB schemas use a mix of all three. The goal is to model data around your query patterns, not around abstract normalization rules.

Shared Tags