MongoDB’s flexible schema is a strength and a trap. You can store anything, but bad data modeling causes slow queries and bloated documents. Good modeling matches your access patterns.

The Key Question: Embed or Reference?

In SQL, you normalize data into separate tables and join them. In MongoDB, you have a choice: embed related data in the same document, or store it separately and reference it.

Embed when:

  • Data is always accessed together
  • Child data belongs to only one parent
  • The embedded data is bounded in size

Reference when:

  • Data is accessed independently
  • Many parents share the same child
  • The child list can grow without bound (e.g., comments on a post)

One-to-One: Always Embed

// Embed address inside user — always accessed together
{
  _id: ObjectId("..."),
  name: "Alex Johnson",
  email: "alex@example.com",
  address: {
    street: "123 Main St",
    city: "Berlin",
    country: "Germany",
    zip: "10115"
  }
}

No need to split this into two collections.

One-to-Few: Embed

When the “many” side is small and bounded (e.g., a user’s phone numbers, a post’s tags):

// Embed phone numbers — a user has at most a few
{
  _id: ObjectId("..."),
  name: "Alex Johnson",
  phones: [
    { type: "mobile", number: "+49123456789" },
    { type: "work",   number: "+49987654321" }
  ]
}

One-to-Many: Reference

When the “many” side is large or grows unbounded (e.g., orders for a user, comments on a post):

// Users collection
{ _id: ObjectId("user1"), name: "Alex", email: "alex@example.com" }

// Orders collection — reference user by ID
{ _id: ObjectId("ord1"), user_id: ObjectId("user1"), total: 99.99 }
{ _id: ObjectId("ord2"), user_id: ObjectId("user1"), total: 45.00 }

To fetch a user’s orders:

db.orders.find({ user_id: ObjectId("user1") })

Many-to-Many

Use an array of references on one side (or both):

// Posts collection
{
  _id: ObjectId("post1"),
  title: "MongoDB Tips",
  tag_ids: [ObjectId("tag1"), ObjectId("tag2")]
}

// Tags collection
{ _id: ObjectId("tag1"), name: "mongodb" }
{ _id: ObjectId("tag2"), name: "database" }

If you need to find all posts for a tag:

db.posts.find({ tag_ids: ObjectId("tag1") })

The Outlier Pattern

When most documents are small but a few are very large, use the outlier pattern:

// Most posts have few comments — embed them
{
  _id: ObjectId("post1"),
  title: "Normal Post",
  comments: [
    { author: "Sam", text: "Great post!" }
  ],
  has_extra_comments: false
}

// Viral post with thousands of comments
{
  _id: ObjectId("post2"),
  title: "Viral Post",
  comments: [ /* first 100 */ ],
  has_extra_comments: true
}

// Extra comments in separate collection
{ post_id: ObjectId("post2"), comments: [ /* next 100 */ ] }

The Bucket Pattern (Time-Series)

For time-series data (metrics, logs, IoT), grouping events into “buckets” reduces document count dramatically:

// Instead of one document per measurement (millions of docs):
{ sensor_id: "s1", timestamp: ISODate("..."), value: 23.5 }
{ sensor_id: "s1", timestamp: ISODate("..."), value: 23.7 }

// Use one document per hour per sensor:
{
  sensor_id: "s1",
  hour: ISODate("2026-09-14T10:00:00Z"),
  count: 60,
  sum: 1413.0,
  min: 23.1,
  max: 24.2,
  measurements: [
    { minute: 0, value: 23.5 },
    { minute: 1, value: 23.7 },
    // ...60 entries
  ]
}

This reduces storage and makes aggregation queries much faster.

Schema Validation

MongoDB does not enforce schema by default, but you can add validation rules:

db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "email"],
      properties: {
        name: {
          bsonType: "string",
          description: "must be a string and is required"
        },
        email: {
          bsonType: "string",
          pattern: "^.+@.+\\..+$",
          description: "must be a valid email"
        },
        age: {
          bsonType: "int",
          minimum: 0,
          maximum: 150
        }
      }
    }
  },
  validationAction: "error"  // "warn" to log instead of reject
});

Mongoose Schema Example

import mongoose, { Schema } from "mongoose";

const commentSchema = new Schema({
  author:    { type: String, required: true },
  text:      { type: String, required: true, maxlength: 2000 },
  createdAt: { type: Date, default: Date.now },
});

const postSchema = new Schema({
  title:     { type: String, required: true, trim: true },
  slug:      { type: String, required: true, unique: true },
  body:      { type: String, required: true },
  author_id: { type: Schema.Types.ObjectId, ref: "User", required: true },
  tags:      [String],
  comments:  [commentSchema],  // embedded — bounded size
  views:     { type: Number, default: 0 },
  createdAt: { type: Date, default: Date.now },
});

// Populate — replace author_id with the actual user document
postSchema.virtual("author", {
  ref: "User",
  localField: "author_id",
  foreignField: "_id",
  justOne: true,
});

const Post = mongoose.model("Post", postSchema);

// Query with populate
const post = await Post.findById(postId).populate("author", "name email");

Document Size Limit

MongoDB documents have a 16MB BSON size limit. For most use cases (user profiles, orders, posts) this is far more than enough. If you have documents approaching this limit, use the outlier pattern or store large blobs in GridFS.

What’s Next?

Your data model is solid. Now let’s learn the aggregation pipeline — MongoDB’s tool for complex data transformations and analytics.

Next: Database Tutorial #11: MongoDB Aggregation Pipeline