MongoDB – Data Modeling: Embed or Reference

December 17, 20246 min readUpdated 8/24/2026

Every MongoDB schema comes down to one question asked over and over: does this data live inside the document, or does it live somewhere else and get pointed at? Get it right and your hottest query is a single read. Get it wrong and you either drag megabytes around to render a card, or you rebuild joins by hand in application code.

There is a rule that decides most cases, and it fits in six words: embed what is bounded, reference what grows.

Start from the queries, not the entities

Relational modelling starts with entities and normalises. Document modelling starts with the queries and shapes documents to serve them. That inversion is the whole discipline — if you model MongoDB the relational way and then wonder why everything needs a $lookup, this is why.

So before drawing anything, write down the queries that run on every page load. ReelCMS, the short-video CMS this track quotes, has three: the public feed newest-first, one reel by slug, and a creator’s own reels. All three want a reel plus enough of its creator to render a byline. That single sentence determines most of what follows.

The four cases, worked

Here is the reel document those queries produced, with the decision marked on each field:

{
  _id:    ObjectId("..."),
  slug:   "around-the-outside-on-the-final-lap",
  status: "PUBLISHED",
  video:  { url: "...", posterUrl: "...", durationSeconds: 34 },   // EMBEDDED
  creator: { _id: ObjectId("..."), username: "thelastlap",         // REFERENCED + copied
             displayName: "The Last Lap", avatarUrl: "..." },
  tags:   ["motorsport", "overtake"],                              // EMBEDDED
  collectionIds: [ObjectId("...")],                                // REFERENCED
  stats:  { views: 4962, likes: 447, comments: 12, shares: 31 }    // EMBEDDED
}
// comments live in their own collection — NOT here

1. video — embedded, because it is 1:1

A video belongs to exactly one reel and no query ever wants one without the other. Splitting it into its own collection would buy a join and nothing else. This is the easy case: 1:1 data that is always read together is embedded, always.

2. tags — embedded, because it is bounded

A reel has a handful of tags, and it will still have a handful in five years. They are always displayed and often queried, and an array field gets a multikey index automatically, so { tags: "motorsport" } is an index seek rather than a scan. Bounded, small, queried: embed.

3. stats — embedded, because atomicity is free there

Four counters that change constantly. Embedding them means an update is a single-document operation, and single-document writes in MongoDB are atomic without a transaction:

db.reels.updateOne({ _id: id }, { $inc: { "stats.views": 1 } })

Put those counters in a separate collection and you need a transaction to keep them consistent with the reel, or you accept drift. Things that must change together want to be in the same document — that is one of the strongest arguments for embedding, and it is about correctness rather than speed.

4. comments — referenced, and this is the one that matters

The instinct is to embed them. For a small blog that is right. Here it is wrong, and it fails in three different ways at three different scales:

  1. Unbounded growth. A document is capped at 16 MB. A reel that goes viral collects hundreds of thousands of comments and eventually cannot accept another one — a write failure on your single most successful piece of content.
  2. Read amplification. Every feed query would pull the whole thread off disk to render a card that shows none of it. Projection hides it from the response; the document is still read.
  3. Write contention. Appending to an array rewrites the document, while stats is being $inc’d on the same document by every view.

So comments get their own collection with a reelId pointing back, and an index on { reelId: 1, createdAt: -1 } to fetch a thread. The test is not “is this a list? ” — tags are a list. The test is “can this list grow without limit? ”

The interesting case: referenced and embedded at once

creator is both, and it is the decision worth understanding properly.

The _id is a genuine reference — creators owns the profile. But three display fields are copied onto every reel, so the feed renders a byline without joining anything. In Java that copy is its own small type:

public class CreatorRef {
    private String id;
    private String username;
    private String displayName;
    private String avatarUrl;
}

What that buys is the hottest query in the system staying a single-collection index scan. What it costs arrives the first time somebody renames themselves: the copy is now stale on every reel they own, and the application — not the database — has to fix it.

public long refreshCreatorSnapshot(String creatorId, String username, String displayName, String avatarUrl) {
    var result = mongo.updateMulti(
            Query.query(Criteria.where("creator.id").is(creatorId)),
            new Update()
                    .set("creator.username", username)
                    .set("creator.displayName", displayName)
                    .set("creator.avatarUrl", avatarUrl),
            Reel.class);
    return result.getModifiedCount();
}

One statement, not a loop — which is what keeps the migration path open when a creator has a million reels and this becomes a background job.

The rule for deciding what to copy is narrow: denormalize fields that are read constantly and change rarely. A display name qualifies; it changes twice in its life against millions of reads. A follower count does not — it moves every minute, so ReelCMS deliberately leaves it out of the snapshot and reads it from creators on the profile page, where one extra query is affordable.

Notice that the trade is only visible because someone wrote the read-to-write ratio down. If it were reversed — a field that changes constantly and is read rarely — the same design would be obviously wrong.

Many-to-many

A reel belongs to several collections, and a collection holds several reels. ReelCMS stores ids on both sides: collectionIds on the reel, reelIds on the collection.

That is deliberate duplication, and it is justified by both directions being queried — “what is in this collection” for the public page, “which collections is this reel in” for the editor. Storing one side only means one of those questions needs a scan of the other collection. The price is that the application keeps the two in step, which is a real maintenance burden and the reason you should not do this by reflex. With one hot direction, store one side.

When embedding is simply wrong

Three signals, any one of which means reference:

  • The nested thing is queried on its own — if you ever want “all comments by this person, across reels”, comments are an entity, not a field.
  • The nested thing is shared. Embedding a creator profile in every reel is not a snapshot, it is four hundred copies of a record with no owner.
  • The array has no natural ceiling. If you cannot name the maximum, there is not one.

The short version

  • Model from your queries, not your entities. Write the top three down first.
  • Embed what is bounded, reference what grows.
  • 1:1 data read together is always embedded.
  • Things that must change together belong in one document — that buys atomicity for free.
  • The 16 MB cap is a real ceiling, and it bites hardest on your most popular content.
  • Copy fields that are read constantly and change rarely; be ready to fan the update out.
  • Duplicate a many-to-many on both sides only when both directions are genuinely hot.