Tutorials

MongoDb Schema Design

Key Takeaways

  • Design around your reads and writes first; relationship cardinality is an input, not the whole decision.
  • Embed small, bounded relationships that you usually read and update together.
  • Use child-side references or relationship collections when records grow without a useful bound or need independent queries.
  • Index referenced relationships to match their filters and sort order.
  • MongoDB has a 16 MiB BSON document limit and single-document write atomicity. Duplicated data needs an explicit consistency policy.

MongoDB schema design means choosing where related data lives based on how your application reads it, changes it, and expects it to grow. Embed data that belongs together, reference independently managed or unbounded data, and selectively duplicate fields when the read benefit justifies the maintenance.

MongoDB has a flexible document model, not an absence of schema. Documents can have different fields and types, but you can enforce collection validation rules and validate data in application code.

Preface: Access Patterns and Cardinality

If you're coming from relational tables, don't simply turn every table into a collection—or combine everything into one document. Start with three questions:

  • What does each screen or endpoint read? Include filters, ordering, page size, and whether children are queried independently.
  • How often will I update this data? Identify fields that change together and records that receive concurrent writes.
  • How large will documents grow? Consider the busiest user, not just the average user.

Cardinality describes how many related records an entity can have. The one-to-few, one-to-many, and one-to-infinity framing is useful, but these aren't MongoDB-enforced categories or numeric thresholds. “Infinity” means the relationship has no useful application-defined bound.

Consider Sam and two posts. A relational representation might look like this:

users
id        name  email
user:112  Sam   sam@example.com

posts
id        title           location      author_id
post:234  favorite pizza  Los Angeles   user:112
post:432  need a break?   Sacramento    user:112

The equivalent embedded shape puts the posts inside Sam's document. This is an illustrative alternative, not part of the executable examples below:

{
  "_id": "user:112",
  "name": "Sam",
  "posts": [
    { "_id": "post:234", "title": "favorite pizza", "location": "Los Angeles" },
    { "_id": "post:432", "title": "need a break?", "location": "Sacramento" }
  ]
}

Embedding can avoid a separate fetch when you need the user and posts together. It isn't automatically faster: document size, returned data volume, independent post queries, and update frequency still matter.

Related data
|
+-- Unbounded growth or independent lifecycle? --> Reference
|
+-- Bounded, usually read/changed together? ------> Embed
|
+-- Repeated read still too expensive? ----------> Consider a bounded copy
                                                   with a consistency policy

This follows MongoDB's data-modeling guidance: access patterns, growth, duplication, and write behavior all influence the choice.

Modeling Relationships in MongoDB

Run the executable blocks in order in mongosh, using a fresh, disposable database with unsharded collections. The examples use string identifiers consistently: Sam is user:112, Fred is user:234, and posts have separate identifiers. Rerunning the inserts unchanged produces duplicate-key errors.

MongoDB One-to-Few: Embed Bounded Data

Suppose your application allows at most eight role assignments per user and usually reads them with the account. Embedding is a reasonable starting point:

db = db.getSiblingDB("schema_design_demo");

db.users.insertMany([
  {
    _id: "user:112",
    name: "Sam",
    email: "sam@example.com",
    createdAt: new Date("2026-10-01T12:00:00Z"),
    updatedAt: new Date("2026-10-01T12:00:00Z"),
    roles: [
      { title: "admin", canEdit: true },
      { title: "customer", canEdit: false }
    ]
  },
  { _id: "user:234", name: "Fred", roles: [] }
]);

// Find users with an embedded admin assignment.
db.users.find({ "roles.title": "admin" }, { name: 1, roles: 1 });

// Change one embedded permission and its timestamp together.
db.users.updateOne(
  { _id: "user:112" },
  { $set: { "roles.0.canEdit": false, updatedAt: new Date() } }
);

Embedded fields remain queryable through dot notation; embedding doesn't make roles invisible to queries. The tradeoff is identity and lifecycle: these are user-owned assignments, not independent role documents.

If “admin” is a shared canonical role whose permissions must change centrally, store its definition separately and reference it from assignments. Don't duplicate shared policy merely because each user has few roles.

MongoDB One-to-Many: Reference Posts from the Child

A bounded list of post IDs on the user is possible. This partial document illustrates parent-side referencing:

{ "_id": "user:112", "postIds": ["post:234", "post:432"] }

But replacing embedded posts with IDs doesn't eliminate unbounded growth. The array still grows with every post, and every insertion still changes the parent. For an expanding publishing history, put the author reference on each post instead:

db.posts.insertMany([
  {
    _id: "post:234", authorId: "user:112",
    title: "favorite pizza", location: "Los Angeles",
    createdAt: new Date("2026-10-02T12:00:00Z")
  },
  {
    _id: "post:432", authorId: "user:112",
    title: "need a break?", location: "Sacramento",
    createdAt: new Date("2026-10-03T12:00:00Z")
  }
]);

db.posts.createIndex({ authorId: 1, createdAt: -1, _id: -1 });

// Separate reads your application can combine.
db.users.findOne({ _id: "user:112" }, { name: 1 });
db.posts.find(
  { authorId: "user:112" },
  { title: 1, location: 1, createdAt: 1 }
).sort({ createdAt: -1, _id: -1 }).limit(20);

The compound index matches equality on authorId followed by the requested sort. Including _id breaks ties between identical timestamps. You don't need an additional single-field authorId index just to support this query; it's already the compound index's leading field.

Posts can now be queried independently—for example, by location—with indexes designed for those separate queries. See MongoDB indexing fundamentals for the underlying tradeoffs.

Resolve References with Separate Queries or $lookup

References need resolution, but that doesn't always mean multiple client requests. MongoDB's aggregation $lookup stage performs server-side joins. This returns Sam with the same newest-post selection:

db.users.aggregate([
  { $match: { _id: "user:112" } },
  {
    $lookup: {
      from: "posts",
      localField: "_id",
      foreignField: "authorId",
      pipeline: [
        { $sort: { createdAt: -1, _id: -1 } },
        { $limit: 20 },
        { $project: { title: 1, location: 1, createdAt: 1 } }
      ],
      as: "posts"
    }
  },
  { $project: { name: 1, posts: 1 } }
]);

Separate queries let you fetch or cache each resource independently. A lookup can reduce client round trips, but its cost depends on the join, indexes, and number of input and matching documents. Keep the joined result bounded; joining an entire publishing history into one result recreates the oversized-document problem.

For more query shapes, see how $lookup works in MongoDB. Use MongoDB projections to return only the fields the caller needs.

MongoDB One-to-Infinity: Use a Relationship Collection

Followers demonstrate unbounded growth and many-to-many relationships: each user can follow many users and be followed by many users. Store one directed relationship per document rather than complete arrays on both user documents.

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

db.follows.insertOne({
  _id: "follow:234:112",
  followerId: "user:234",  // Fred
  followingId: "user:112", // Sam
  createdAt: new Date("2026-10-04T12:00:00Z")
});

// Who follows Sam?
db.follows.find({ followingId: "user:112" })
  .sort({ followerId: 1 }).limit(20);

// Whom does Fred follow?
db.follows.find({ followerId: "user:234" })
  .sort({ followingId: 1 }).limit(20);

// Count Sam's incoming relationships.
db.follows.countDocuments({ followingId: "user:112" });

The unique compound index prevents duplicate follower/following pairs. The reverse index supports the incoming direction. Both directions use the same authoritative relationship document, and adding a relationship doesn't enlarge either user document.

Counting matching relationships still requires work proportional to the matches examined; an index doesn't make every count constant-time. If counts become a hot read, design a separate maintained counter rather than keeping every follower ID solely to count them.

Advertisement

Two Way Referencing and Consistency

Two way referencing stores navigation links in both directions. For Fred following Sam, the partial user documents might look like this:

[
  { "_id": "user:112", "followerIds": ["user:234"] },
  { "_id": "user:234", "followingIds": ["user:112"] }
]

If you also retain the follows document, the relationship now exists in three places. Following or unfollowing must update all three representations. Sequential writes can fail halfway through, and concurrent operations can leave the arrays inconsistent.

MongoDB writes are atomic at the single-document level, including changes to multiple embedded fields in one write. Separate writes to multiple documents aren't automatically atomic.

Multi-document transactions are available on replica sets and sharded clusters when all-or-nothing changes are required. They usually cost more than single-document writes and don't solve unbounded arrays or a heavily updated parent.

For a social graph, prefer the relationship collection and its two indexes. If you intentionally maintain derived arrays or counters, choose transactions for immediate consistency or durable asynchronous updates with retries and reconciliation. Reconciliation means periodically rebuilding or checking derived state against the authoritative relationships.

Denormalization: Copies, Caches, and Snapshots

Denormalization duplicates selected data to simplify reads. For example, Sam's profile might include a bounded follower preview. This is an illustrative partial shape, not another complete follower list:

{
  "_id": "user:112",
  "followerPreview": [
    { "userId": "user:234", "name": "Fred" }
  ]
}

Keep the preview to a small application-defined maximum, such as three entries, and retain the complete graph in follows. Duplicate only what the view needs—not entire user profiles.

Decide what each copy means:

  • Current canonical value: Resolve the reference when you need the user's current name or centrally managed permissions.
  • Display cache: A copied follower name may tolerate staleness. Define its refresh deadline and make the preview rebuildable.
  • Historical snapshot: A post's authorSnapshot.name can intentionally preserve the publishing-time name. A later rename shouldn't automatically rewrite that history.

Not every copied value should mirror its source forever. Document the consistency requirement, update ownership, and repair process before introducing the copy.

Enforce the Document Shape with Validation

Application validation gives callers useful errors; collection validation also protects writes from other clients. For the existing users collection, collMod can add rules that require string identifiers and names and enforce the example's eight-role bound:

db.runCommand({
  collMod: "users",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["_id", "name", "roles"],
      properties: {
        _id: { bsonType: "string" },
        name: { bsonType: "string" },
        roles: {
          bsonType: "array",
          maxItems: 8,
          items: {
            bsonType: "object",
            required: ["title", "canEdit"],
            properties: {
              title: { bsonType: "string" },
              canEdit: { bsonType: "bool" }
            }
          }
        }
      }
    }
  },
  validationLevel: "strict",
  validationAction: "error"
});

Here, maxItems limits array length, and the explicit validation settings reject invalid inserts and updates unless validation is bypassed with the required privileges. Adding these rules doesn't retroactively repair existing documents; audit and migrate old data separately.

Advertisement

MongoDB Schema Design for a Real-World Profile

Suppose a profile displays account details, the newest 20 posts, follower navigation, and a follower count. Roles rarely change; posts and follows keep accumulating.

Access patternModelReason
Read account and bounded role assignmentsEmbed assignments in usersThey belong to the account and can change in one write.
Read newest posts or search posts independentlyReference users from posts.authorIdPost growth doesn't enlarge the user document.
Navigate followers and followingUse follows with indexes in both directionsOne relationship record supports both queries.
Display follower count frequentlyStart with a relationship count; consider a maintained counter after measurementA counter adds synchronization work and can become a write hotspot.

Publishing a post now inserts one post document without appending to a user array. Following someone inserts one relationship document; if you later add a counter, that operation also needs the chosen transaction or asynchronous maintenance workflow.

Before shipping, test representative large accounts and concurrent writes. Inspect query plans with explain("executionStats"), measure response sizes, and verify that indexes match the actual filters and sorts. Small example datasets don't establish production performance.

Common Mistakes

  • Using average cardinality as the bound. Decide what happens when one account grows far beyond the average.
  • Replacing documents with an unlimited ID array. References aren't automatically bounded.
  • Duplicating everything to avoid joins. Copy only fields with a measured read benefit and a clear freshness policy.
  • Treating two indexes as duplicated relationship state. Indexing both directions doesn't require maintaining two relationship documents.
  • Ignoring deletion and repair. Define how you'll handle references to deleted users, stale previews, and incorrect counters.

The goal isn't maximum embedding or maximum normalization. It's a predictable read path, bounded documents, and a write path whose consistency requirements you can actually maintain.

FAQ

When should I embed documents instead of referencing them in MongoDB?

Embed related data when it has a useful size bound, usually belongs to the parent, and is commonly read or changed with it. Prefer references when children grow without bound, need independent queries, or have a separate lifecycle.

How do you model a one-to-many relationship in MongoDB?

Embed small, bounded child collections that you normally access with the parent. For growing collections such as posts, store the parent's identifier on each child and create an index matching the relationship query and sort order.

Does MongoDB support joins between referenced documents?

Yes. The aggregation $lookup stage joins related documents on the server; applications can also resolve references with separate queries. Choose based on the access pattern, supporting indexes, result size, and measured workload.

What is MongoDB's maximum document size?

The maximum BSON document size is 16 mebibytes (MiB), including embedded documents and arrays. Avoid unbounded arrays well before reaching that ceiling because large documents can create resource and update problems.

Are MongoDB updates atomic across multiple documents?

A write is atomic within one document, including its embedded fields. Changes spanning multiple documents require a transaction for all-or-nothing behavior; MongoDB supports transactions on replica sets and sharded clusters.

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. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me

Advertisement