MongoDB Schema Design: Embed vs Reference and the Patterns That Follow

Key takeaways

MongoDB schema design is fundamentally different from SQL — you model around your queries, not around normalization rules. This guide covers the embed-vs-reference decision, relationship patterns, and real-world schema patterns.

Why Schema Design Is Different in MongoDB

In SQL, you normalize to eliminate redundancy. In MongoDB, you denormalize for query performance — store related data together so a single document read answers the query.

The core question for every relationship: How will this data be accessed?

The most common way this goes wrong is a migration from SQL that maps each table to a collection one-to-one. The result has all the joins of the relational design, now done with $lookup or several application queries, and none of the benefits of either model. When I have seen this, the fix was not tuning but redesigning: list the queries the application actually runs, then shape documents so the frequent ones read a single document.

“Schemaless” also does not mean “no schema”. The schema still exists; it just lives in your application code and in the shape of the documents already written. Deciding it explicitly, and enforcing the parts that matter with validation (section 4), is what keeps a MongoDB database maintainable after a year of feature work.


Embedding vs. Referencing

Embed when:

  • Data is always read together
  • Embedded data is small and has a bounded size
  • Relationship is “owned by” (user owns their addresses)
  • Data isn’t shared across documents
// EMBEDDED: user with addresses
{
  _id: ObjectId("..."),
  name: "Alice",
  email: "[email protected]",
  addresses: [
    { type: "home", street: "123 Main St", city: "Seoul", zip: "04524" },
    { type: "work", street: "456 Tower Ave", city: "Seoul", zip: "06236" }
  ]
}

Single db.users.findOne({ _id: userId }) returns everything.

Reference when:

  • Data is large or grows without bound
  • Data is accessed independently
  • Data is shared across many documents
  • You need to update one piece of data in many places
// REFERENCED: post with author
// posts collection
{
  _id: ObjectId("post-1"),
  title: "MongoDB Schema Design",
  authorId: ObjectId("user-1"),  // reference
  tags: ["mongodb", "database"]
}

// users collection
{
  _id: ObjectId("user-1"),
  name: "Alice",
  email: "[email protected]"
}

To get post + author: $lookup or two separate queries.

A checklist for each relationship

QuestionFavors embeddingFavors referencing
Read together?Almost alwaysRarely, or only part of it
Updated together?Usually in the same operationFrequently on their own
How many children?A few, with a known upper boundThousands, or unbounded
Shared by several parents?NoYes
Must stay consistent with the parent?Yes: one-document updates are atomicCan tolerate app logic or a transaction

When the answers conflict, the access pattern that runs most often usually wins, and a hybrid (embed a summary, reference the full data) resolves the rest.

Performance trade-offs

AspectEmbeddedReferenced
Read on the common pathOne findTwo finds or a $lookup
Write contentionHotspot if many writers update the same documentWrites spread across documents
Document growthParent grows with its children; large documents cost more to read and rewriteParent stays small
ConsistencySingle-document updates are atomicNeeds application logic or multi-document transactions
DuplicationEmbedded copies must be kept in sync if the source changesOne source of truth

Write contention is the trade-off people miss. Every update to an embedded array locks and rewrites that one document at the storage level, so a “post with embedded comments” becomes a bottleneck on a viral post where hundreds of users comment per second, even if each comment is small. Referencing spreads those writes across separate documents.


Relationship Patterns

One-to-One

Always embed unless the embedded document is large or rarely accessed together.

// Embed profile inside user (always needed together)
{
  _id: ObjectId("..."),
  email: "[email protected]",
  profile: {
    bio: "Software engineer",
    avatar: "https://...",
    location: "Seoul"
  }
}

// Reference if profile is large or separately managed
{
  _id: ObjectId("user-1"),
  email: "[email protected]",
  profileId: ObjectId("profile-1")  // separate collection
}

One-to-Few (embed)

When the “many” side is small and bounded (e.g., ≤ 10 items), embed.

// Blog post with tags (bounded, small)
{
  _id: ObjectId("..."),
  title: "My Post",
  tags: ["tech", "mongodb", "backend"]
}

// Order with line items (bounded, always needed together)
{
  _id: ObjectId("..."),
  userId: ObjectId("..."),
  total: 129.99,
  lineItems: [
    { productId: ObjectId("..."), name: "Widget", qty: 2, price: 49.99 },
    { productId: ObjectId("..."), name: "Gadget", qty: 1, price: 30.01 }
  ]
}

One-to-Many (reference on the “many” side)

When the “many” side can grow large (comments, orders, logs), put the reference on the child.

// posts collection
{ _id: ObjectId("post-1"), title: "My Post" }

// comments collection — reference points to parent
{ _id: ObjectId("..."), postId: ObjectId("post-1"), text: "Great post!", userId: ObjectId("...") }
{ _id: ObjectId("..."), postId: ObjectId("post-1"), text: "Thanks!", userId: ObjectId("...") }

// Query: get all comments for a post
db.comments.find({ postId: ObjectId("post-1") }).sort({ createdAt: -1 })

One-to-Many with partial embed (hybrid)

Store a subset of frequently accessed fields in the parent to avoid joins for common queries:

// Store the 3 most recent comments in the post (for display);
// all comments still live in their own collection
{
  _id: ObjectId("post-1"),
  title: "My Post",
  commentCount: 247,
  recentComments: [
    { userId: ObjectId("..."), username: "Bob", text: "Great!", createdAt: ISODate("...") },
    { userId: ObjectId("..."), username: "Carol", text: "Thanks!", createdAt: ISODate("...") }
  ]
}

Keeping the embedded list bounded is done in the same update that adds a comment, with $push, $each, $sort, and $slice:

db.posts.updateOne(
  { _id: postId },
  {
    $inc: { commentCount: 1 },
    $push: {
      recentComments: {
        $each: [{ userId, username, text, createdAt: new Date() }],
        $sort: { createdAt: -1 },
        $slice: 3
      }
    }
  }
)

The cost of this pattern is duplication: username is copied into every recent comment. If users can rename themselves, decide up front whether old snapshots may keep the old name (often acceptable for comments) or whether a background job must update them.

Many-to-Many

Use arrays of references on one or both sides depending on access patterns.

// Students enrolled in courses
// students collection
{
  _id: ObjectId("student-1"),
  name: "Alice",
  enrolledCourseIds: [ObjectId("course-1"), ObjectId("course-2")]
}

// courses collection
{
  _id: ObjectId("course-1"),
  name: "MongoDB Fundamentals",
  // Don't duplicate student array here unless needed
}

// Query students in a course
db.students.find({ enrolledCourseIds: ObjectId("course-1") })

// Query courses for a student
db.courses.find({ _id: { $in: student.enrolledCourseIds } })

Schema Patterns

Bucket Pattern

Group time-series data into buckets to reduce document count and enable efficient range queries.

Problem: Storing one document per sensor reading creates millions of tiny documents.

// NAIVE: one doc per reading (bad)
{ sensorId: "s1", timestamp: ISODate("2026-04-16T10:00:00"), value: 22.5 }
{ sensorId: "s1", timestamp: ISODate("2026-04-16T10:01:00"), value: 22.7 }
// ... 1440 documents per sensor per day

// BUCKET: one doc per hour
{
  sensorId: "s1",
  date: ISODate("2026-04-16"),
  hour: 10,
  readings: [
    { minute: 0, value: 22.5 },
    { minute: 1, value: 22.7 },
    // ... up to 60 readings
  ],
  count: 60,
  avgValue: 22.6,
  minValue: 22.1,
  maxValue: 23.0
}

Benefits: 60× fewer documents, pre-computed aggregates, efficient range scans.

Outlier Pattern

Handle the rare case where a document would grow unbounded by splitting overflow to a separate collection.

// Product with reviews — most products have <50 reviews
{
  _id: ObjectId("product-1"),
  name: "Popular Gadget",
  reviews: [ /* first 50 reviews embedded */ ],
  reviewCount: 15000,
  hasExtraReviews: true   // flag signals overflow
}

// Overflow collection for products with many reviews
// product_reviews_overflow collection
{
  productId: ObjectId("product-1"),
  page: 2,
  reviews: [ /* reviews 51-150 */ ]
}

Application checks hasExtraReviews and queries the overflow collection only when needed. The pattern optimizes for the common case: the 99% of products with few reviews stay a single read, and only the rare outlier pays for a second query.

Computed Pattern

Pre-compute expensive aggregations and store the result to avoid recalculating on every read.

// Instead of aggregating every request...
db.orders.aggregate([
  { $match: { customerId: ObjectId("...") } },
  { $group: { _id: null, total: { $sum: "$amount" }, count: { $sum: 1 } } }
])

// Store computed stats on the customer document
{
  _id: ObjectId("customer-1"),
  name: "Alice",
  stats: {
    totalOrders: 47,
    totalSpent: 3847.50,
    avgOrderValue: 81.86,
    lastOrderDate: ISODate("2026-04-10")
  }
}

// Update stats when a new order is placed
// (avgOrderValue can be derived from totalSpent / totalOrders at read time)
db.customers.updateOne(
  { _id: customerId },
  {
    $inc: { "stats.totalOrders": 1, "stats.totalSpent": orderAmount },
    $set: { "stats.lastOrderDate": new Date() }
  }
)

The computed values can drift from the source data if an order insert succeeds and the stats update fails. Either run both writes in a transaction, or accept eventual consistency and periodically recompute the stats from orders, which is what most systems do for counters like likes or views.

Schema Versioning Pattern

Add a schemaVersion field to handle schema evolution without downtime migrations.

// Version 1 (old documents)
{ _id: ..., schemaVersion: 1, name: "Alice Smith" }

// Version 2 (new documents)
{ _id: ..., schemaVersion: 2, firstName: "Alice", lastName: "Smith" }

// Application handles both
function getUserName(user) {
  if (user.schemaVersion >= 2) {
    return `${user.firstName} ${user.lastName}`;
  }
  return user.name;  // fallback for v1
}

// Lazy migration: upgrade on read
async function getUser(id) {
  const user = await db.users.findOne({ _id: id });
  if (user && (user.schemaVersion ?? 1) < 2) {
    const [firstName, ...rest] = user.name.split(' ');
    await db.users.updateOne(
      { _id: id, schemaVersion: user.schemaVersion },  // skip if another request already migrated it
      { $set: { schemaVersion: 2, firstName, lastName: rest.join(' ') }, $unset: { name: "" } }
    );
    return { ...user, schemaVersion: 2, firstName, lastName: rest.join(' ') };
  }
  return user;
}

Indexing for Schema Patterns

Compound indexes for embedded fields

// Document
{
  _id: ObjectId("..."),
  name: "Alice",
  address: { city: "Seoul", zip: "04524" }
}

// Index on embedded fields with dot notation
db.users.createIndex({ "address.city": 1, "address.zip": 1 })

Multikey indexes for arrays

// Automatically handles arrays — one index entry per array element
db.posts.createIndex({ tags: 1 })

// Query any element in array
db.posts.find({ tags: "mongodb" })

Multikey indexes grow with array length (a document with 200 tags adds 200 index entries), and a compound index can contain at most one array field. Both are another reason to keep embedded arrays bounded.

Tenant-leading compound indexes

In a multi-tenant application where every query filters by tenantId, put tenantId first in every compound index:

db.orders.createIndex({ tenantId: 1, status: 1, createdAt: -1 })
db.orders.find({ tenantId, status: "pending" }).sort({ createdAt: -1 })

This follows the usual order for compound indexes: equality fields first, then sort fields, then range fields. It also makes a forgotten tenantId filter show up as a collection scan in explain() rather than silently returning other tenants’ data quickly.

db.products.createIndex({ name: "text", description: "text" })

db.products.find({ $text: { $search: "wireless headphones" } })

Partial indexes — only index relevant documents

// Only index active users (saves space and write overhead)
db.users.createIndex(
  { email: 1 },
  { partialFilterExpression: { isActive: { $eq: true } } }
)

// Only index unpaid orders
db.orders.createIndex(
  { createdAt: 1 },
  { partialFilterExpression: { status: { $in: ["pending", "processing"] } } }
)

TTL index — auto-expire documents

// Sessions expire after 24 hours
db.sessions.createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 })

// Logs expire at a specific field value
db.logs.createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 })
// Set expireAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) on insert

Schema validation

MongoDB can enforce the parts of the schema that must always hold, with a $jsonSchema validator on the collection:

db.createCollection("orders", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["userId", "status", "lineItems", "createdAt"],
      properties: {
        status: { enum: ["pending", "processing", "completed", "cancelled"] },
        lineItems: {
          bsonType: "array",
          maxItems: 500,          // guard against unbounded growth
          items: {
            bsonType: "object",
            required: ["productId", "qty", "price"],
            properties: { qty: { bsonType: "int", minimum: 1 } }
          }
        }
      }
    }
  },
  validationAction: "error"  // "warn" logs violations instead of rejecting
})

Validation does not replace application-level checks, but it catches the bugs that otherwise surface months later, such as a code path that writes status: "Completed" or a string where a number was expected. On an existing collection, add it with collMod and start with validationAction: "warn" to see what existing data would fail.


Aggregation Pipeline

// Sales report: total revenue by category, last 30 days
db.orders.aggregate([
  // Stage 1: filter recent orders
  { $match: {
    status: "completed",
    createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
  }},

  // Stage 2: unwind line items (array → multiple docs)
  { $unwind: "$lineItems" },

  // Stage 3: group by category
  { $group: {
    _id: "$lineItems.category",
    revenue: { $sum: { $multiply: ["$lineItems.price", "$lineItems.qty"] } },
    orderCount: { $sum: 1 }
  }},

  // Stage 4: sort and limit
  { $sort: { revenue: -1 } },
  { $limit: 10 },

  // Stage 5: rename _id
  { $project: { category: "$_id", revenue: 1, orderCount: 1, _id: 0 } }
])

$lookup (join)

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

$lookup runs one lookup into users for every post that reaches that stage, so it needs an index on the foreign field (_id always has one) and benefits from a $match and $limit before it, not after. It is a good fit for reports and admin screens. If it appears on every request of a hot path, that is usually a sign that the author’s name and avatar should be embedded in the post as a snapshot.


Common Mistakes

Mistake 1: Embedding unbounded arrays

// AVOID: users array grows indefinitely
{
  _id: ObjectId("post-1"),
  title: "Popular Post",
  likes: [
    ObjectId("user-1"),
    ObjectId("user-2"),
    // ... could be millions
  ]
}

// FIX: separate likes collection or store count only
{
  _id: ObjectId("post-1"),
  title: "Popular Post",
  likeCount: 15420
}
// Separate: db.likes.insertOne({ postId, userId, createdAt })

Mistake 2: Treating MongoDB like SQL (too many collections, no embedding)

// OVER-NORMALIZED (bad for MongoDB)
// users collection, profiles collection, addresses collection...
// Requires $lookup for every user read

// BETTER: embed what's always needed together
{
  _id: ObjectId("..."),
  email: "[email protected]",
  profile: { bio: "...", avatar: "..." },
  addresses: [{ type: "home", ... }]
}

Mistake 3: Missing indexes on foreign key fields

// If you store parentId references, always index them
db.comments.createIndex({ postId: 1, createdAt: -1 })
db.orders.createIndex({ userId: 1 })

Unlike most SQL databases’ foreign-key tooling, nothing in MongoDB reminds you. Without the index, db.comments.find({ postId }) scans the whole collection, which is fast in development with 200 comments and becomes the slowest query in production. Check with .explain("executionStats"): COLLSCAN or a totalDocsExamined far above nReturned means a missing or wrong index.

Mistake 4: Updating large embedded arrays by rewriting them

Reading a document, modifying an array in application code, and writing the whole document back is slow for large arrays and loses concurrent updates (last write wins). Update elements in place instead:

// Mark one line item as shipped without touching the others
db.orders.updateOne(
  { _id: orderId },
  { $set: { "lineItems.$[item].shipped": true } },
  { arrayFilters: [{ "item.productId": productId }] }
)

Real-World Cases

  • E-commerce orders: the order header and its line items are embedded, because they are created together, read together, and the count is bounded. Each line item stores a snapshot of the product name and price at purchase time, while the product master stays in its own collection. The duplication here is a feature: the order must not change when the catalog price does.
  • Social feeds and likes: posts are separate documents; likes live in their own collection ({ postId, userId } with a unique index to prevent double likes), and the post carries a denormalized likeCount updated with $inc. The count can be briefly out of step with the likes collection, which is an acceptable trade for not scanning likes on every feed render.
  • B2B multi-tenant SaaS: every document carries tenantId, every index starts with it, and the data access layer adds it to every query so it cannot be forgotten.
  • IoT or metrics: readings use the bucket pattern (or MongoDB’s native time-series collections since 5.0, which bucket automatically), with the bucket size chosen so a document stays well under a few hundred KB.

Troubleshooting

SymptomLikely causeFix
Document approaching 16MBUnbounded embedded arrayMove children to their own collection, bucket, or apply the outlier pattern
Updates on one document are slow or conflictHot document with a large embedded arraySplit the array out, or use arrayFilters instead of rewriting it
Reads slow despite an indexWorking set larger than RAM, or fetching whole large documentsProject only needed fields; consider a covering index
Orphaned children after deletesNo referential integrity in MongoDBDelete children in the same transaction, or use soft deletes and a cleanup job
Mixed field types in one collectionNo validation, several code versions writingAdd $jsonSchema validation and a schemaVersion field

Decision Guide

Is the relationship "owns" (user → address)?
  → Embed

Will the embedded array grow without bound?
  → Reference (or Bucket/Outlier pattern)

Is the embedded data accessed independently without the parent?
  → Reference

Is the same data referenced from many places?
  → Reference (avoids update anomalies)

Is this time-series data with high write volume?
  → Bucket pattern

Do you compute expensive aggregations on every read?
  → Computed pattern

Are you evolving the schema without a full migration?
  → Schema Versioning pattern