Blog

MongoDB Schema Design: The 2 Questions to Ask Yourself

Key Takeaways

  • Start with the queries and updates your application must perform, not a rule that MongoDB data should always be denormalized.
  • Embed bounded data that's read and managed together; reference shared, independently updated, or unbounded data.
  • Design indexes and consistency rules alongside your documents. References can be resolved with application queries or MongoDB's $lookup.
  • Plan for document growth before it becomes a problem. MongoDB's maximum BSON document size is 16 MiB, but practical performance problems can appear earlier.

MongoDB schema design starts with two questions: How will you query the data? and How will your documents grow? Use the answers to choose embedding, referencing, or a deliberate combination—not relational normalization rules applied automatically. MongoDB's own modeling guidance prioritizes application access patterns.

If you need a refresher on collections and document relationships, start with our MongoDB schema design fundamentals.

Preface: Normalized vs. Denormalized Data

Normalization separates related entities to reduce duplication. In a relational model, Products and Parts might live in separate tables, with a relationship connecting them. Joins assemble the data you need; their cost depends on the query, indexes, data volume, and workload—not simply on whether the database is relational.

MongoDB stores BSON documents, commonly represented as JSON-like objects in application code. BSON is a binary format with types beyond JSON. A Product can embed its Parts, reference separately stored Parts, or copy selected fields while retaining references.

Embedding doesn't necessarily duplicate data: a child owned by one parent can have a single home inside that parent. Duplication happens when, for example, you copy the same shared Part into multiple Products. That can save read-time work, but introduces storage and update costs.

Advertisement

1) How Will You Query the Data?

Write down the operations before drawing the documents. For a Product/Parts application, ask:

  • Does a product page always display its parts?
  • Do users search the parts catalog independently?
  • Can one part appear in many products?
  • How often do part details change, and must every product show the change immediately?

Record filters, sorts, result sizes, and read/write frequency. A model that works for a product detail page may be a poor fit for a parts-management screen.

Embed Parts When They Belong with the Product

If a product has a bounded set of parts that are normally read and managed together, embedding is a reasonable starting point. The following examples are interactive mongosh commands; run them in order against a fresh scratch database. Collection names distinguish the alternative models.

db.productsEmbedded.insertOne({
  _id: "bike-1",
  name: "City Bike",
  parts: [
    { partId: "wheel-1", name: "Wheel" },
    { partId: "brake-1", name: "Disc Brake" }
  ]
});

db.productsEmbedded.findOne({ _id: "bike-1" });

The read returns the product and its embedded parts without joining another collection. However, if these are shared catalog parts, copying their details into many products creates multiple values to maintain when a part changes.

Reference Parts That Have an Independent Lifecycle

Prefer document references when Parts are shared across Products, queried independently, or updated separately. Keep their canonical details—the authoritative values—in a Parts collection.

db.parts.insertMany([
  { _id: "wheel-1", name: "Wheel" },
  { _id: "brake-1", name: "Disc Brake" }
]);

db.productsReferenced.insertOne({
  _id: "bike-1",
  name: "City Bike",
  partIds: ["wheel-1", "brake-1"]
});

db.productsReferenced.aggregate([
  { $match: { _id: "bike-1" } },
  {
    $lookup: {
      from: "parts",
      localField: "partIds",
      foreignField: "_id",
      as: "parts"
    }
  }
]);

References do not require an application-level join. Here, $lookup matches the product's array of IDs against parts._id and adds matching documents as a parts array. An application-side alternative is to fetch the product, then fetch the referenced Parts together.

For an equality lookup, the foreign field should have a supporting index; this example uses the standard collection's automatically created _id index. Benchmark the aggregation against embedding and application-side retrieval using realistic result sizes. See our explanation of MongoDB $lookup for more on the join operation.

Use a Hybrid Model Only with a Duplication Policy

You can keep Part references and copy just their display names into the Product. That lets a product page display names without resolving the references, while detailed Part reads still use the canonical collection.

Embedded                     Referenced                   Hybrid
Product                      Product                      Product
  parts: [{partId, name}]      partIds: [partId]            parts: [{partId, name}]
                                |                            |
                                v                            v
                              Parts collection             Parts collection
                              authoritative details        authoritative details

Decide what the copied name means. If it's a cache of the current name, a rename needs a refresh strategy for affected Products. If it's an intentional historical snapshot, it should remain unchanged. Document who owns synchronization and how much staleness the application accepts.

Choose Embedding vs. Referencing with More Than Cardinality

Cardinality means how many related records an entity can have. The label “one-to-many” isn't enough: a small, bounded relationship and an indefinitely growing relationship need different treatment. Use these factors together.

FactorFavors embeddingFavors referencing
Read patternRelated data is usually read togetherEntities are frequently queried separately
GrowthKnown, manageable upper boundLarge or unbounded relationship
UpdatesData changes togetherShared details change independently
LifecycleChildren belong to and retire with the parentChildren exist independently of a parent
ConsistencyOne document can contain the required atomic changeCross-document consistency is explicitly managed

Design Indexes Alongside the Documents

If the parts-management screen searches by exact name, give that query a supporting index:

db.parts.createIndex({ name: 1 });
db.parts.find({ name: "Wheel" });

Indexes can reduce scanning and support query execution, but consume storage and add maintenance work to writes. Choose them from actual filters and sorts rather than indexing every field. Our introduction to MongoDB indexing explains the trade-offs.

Keep Atomicity Boundaries Explicit

A MongoDB write is atomic at the single-document level: an update that changes a Product and its embedded Parts either applies those changes together or doesn't apply them. Multiple separate writes to that document aren't automatically one atomic operation.

When an invariant—a rule that must always hold—spans documents, consider a multi-document transaction. MongoDB supports these transactions on replica sets and sharded clusters, but they generally cost more than single-document writes. An operation such as updateMany() isn't atomic across all matched documents merely because it's one command.

2) How Will Your Documents Grow?

MongoDB's maximum BSON document size is 16 MiB, not “around 16 MB.” That limit applies to the complete document, including embedded documents and arrays. Treat it as a hard ceiling, not a target document size.

Long before that ceiling, growing arrays can increase resource consumption and hurt index performance. Returning large documents also transfers data the caller may not need. Use MongoDB projections to select required fields, but don't mistake a smaller response for a fix to an unbounded storage model.

Replace the Unbounded Followers Array with Relationship Documents

Consider this illustrative User document:

{
  "_id": "u1",
  "name": "Avery",
  "followers": ["u2", "u3"]
}

The problem isn't those two IDs; it's the absence of a bound. Adding every new follower to this array cannot continue indefinitely. Storing references instead of full follower profiles makes each entry smaller, but doesn't bound the array.

Instead, store each follow relationship in a separate collection, with the relationship pointing back to the followed user:

db.follows.createIndex(
  { followedId: 1, followerId: 1 },
  { unique: true }
);

db.follows.insertMany([
  { followedId: "u1", followerId: "u2" },
  { followedId: "u1", followerId: "u3" }
]);

db.follows.find(
  { followedId: "u1" },
  { _id: 0, followerId: 1 }
).sort({ followerId: 1 }).limit(50);

The compound unique index prevents duplicate (followedId, followerId) pairs. Its field order supports filtering by the followed user and sorting that user's followers by ID. This is ID order, not newest-follower order; a chronological listing needs a timestamp and an appropriate index.

For subsequent pages, add a $gt condition on followerId using the previous page's last returned ID. If you also need “who does this user follow?”, evaluate an index with the fields reversed.

Check the execution plan with explain execution statistics, then test with realistic relationship counts—not just the two sample records. executionStats executes the winning plan and reports its execution statistics.

db.follows.find(
  { followedId: "u1" },
  { _id: 0, followerId: 1 }
).sort({ followerId: 1 }).limit(50).explain("executionStats");

Use Subsets or Buckets When They Match the Read Pattern

  • Subset: Keep a small, explicitly capped preview on the parent and the full relationship set elsewhere. MongoDB's subset pattern fits screens that repeatedly need only a small portion of a larger dataset. Define how the preview stays current.
  • Bucket: Group related entries into multiple bounded documents when reads naturally consume batches or pages. The bucket pattern can fit that access pattern, but replacing one unlimited array with an unlimited bucket doesn't solve growth.

For ordinary follower lookups, start with separate relationship documents. Introduce a preview or bucket only when a measured access pattern justifies the additional maintenance.

Flexible Schema Still Needs Validation and Migrations

By default, documents in a collection don't need identical fields or field types. That flexibility doesn't remove your application's need for consistent identifiers, predictable field shapes, and a migration plan when those shapes change.

Collection validation can require fields and restrict their types. With $jsonSchema validation, bsonType specifies BSON types and maxItems limits an array's length. A count cap doesn't bound total bytes unless element sizes are also controlled.

Consider Shard Keys Before Growth Forces the Decision

Sharding distributes a collection across servers. The shard key affects data placement and whether common queries can target relevant shards instead of broadcasting broadly. Evaluate both query targeting and uneven data distribution.

For followers, a key based only on followedId groups one user's relationships under the same key value. A disproportionately popular user can therefore concentrate data and traffic. Before sharding this model, also review shard-key index and uniqueness restrictions: for a ranged sharded collection, a unique compound index must include the shard key as its prefix.

Advertisement

Conclusion: A Practical MongoDB Schema Design Rule

Embed bounded data that you read and manage together. Reference data that grows independently, changes independently, or belongs to multiple parents. Copy selected fields only when you've defined what those copies mean and how they're maintained.

Before shipping the model:

  • Write the important reads and updates, including filters, sorting, and pagination.
  • Set a growth expectation and an explicit bound for every embedded array.
  • Identify which changes must be atomic and which copied values may be stale.
  • Benchmark representative reads and writes with their indexes, realistic document sizes, and skewed relationship counts.

FAQ

When should I embed documents versus reference them in MongoDB?

Embed related data when it's bounded, usually read together, and shares an update and lifecycle boundary. Reference data that's shared, frequently updated independently, queried separately, or expected to grow without a practical bound.

How should I model an unbounded one-to-many relationship in MongoDB?

Store the many-side records in a separate collection, with each record referencing its parent, and index the parent field plus the fields needed for sorting or pagination. For followers, use one relationship document per follow rather than an unlimited array on the User.

Does MongoDB support joins between collections?

Yes. The aggregation pipeline's $lookup stage joins related data and adds matching documents as an array. Use it when separately stored entities need to be read together, and verify the join's supporting indexes and measured performance.

What is the maximum BSON document size in MongoDB?

The maximum BSON document size is 16 MiB, including embedded documents and arrays. Documents and arrays can become expensive to read, update, or index before reaching that hard limit.

When should I use a MongoDB transaction?

Use a multi-document transaction when a change must succeed or fail together across multiple documents. A single-document write is already atomic; references alone don't mean every operation needs a transaction.

Further Reading

Advertisement

Written by Sam Erickson

Pen name of a software engineer with 20+ years in the industry, from Fortune 500 companies to startups. Newer articles are researched and drafted with AI tools I direct and fact-checked against their sources. Author of Mastering the Tech Interview. More about me

Advertisement