← Back to the section

In a relational database the structure is dictated by normalization: every fact lives in exactly one place, and relationships go through foreign keys. MongoDB has no such rule. For the same data there are several workable models, and the choice between them determines read speed, update complexity, and scalability.

The main question when designing is whether to embed related data into the document or store it separately and reference it. Everything else is built around this choice.

Embed: put everything into one document

Imagine a product and its category. The simplest way is to store the category right inside the product:

{
    _id: 3,
    name: "Candy",
    price: 150,
    category: {
        _id: 1,
        name: "Sweets"
    }
}

The upside is obvious: a single query returns everything. MongoDB guarantees atomic writes for a single document, so no transactions are needed.

The downside is just as obvious: the name "Sweets" is stored in every product of this category. If the category is renamed, you have to update thousands of documents. And there is one more limit — a document in MongoDB cannot exceed 16 MB, so you cannot embed arrays that may grow without bound.

Reference: store separately and reference by id

The second approach is to store the category separately and keep only its identifier in the product:

// product
{ _id: 3, name: "Candy", price: 150, categoryId: 1 }

// separate collection with categories
{ _id: 1, name: "Sweets" }

No duplication: renaming a category is a single operation. Documents stay compact, and there can be millions of them.

But fetching a product with its category now takes two queries, or an aggregation with $lookup — the MongoDB equivalent of a JOIN. In a sharded cluster, if products and categories live on different nodes, $lookup goes over the network.

How to choose: six rules

There is no universal answer, but there are clear criteria:

  1. Data is almost always read together → embed. If 80% of queries are "get the product with its category", embedding the category into the product is the reasonable choice.
  2. Data changes at different rates → reference or partial denormalization. The category changes rarely, the product often: you can store categoryId as a reference and copy categoryName into the product for fast display.
  3. One-to-few relationship (product → 2–5 photos) → embed an array.
  4. One-to-many relationship (product → 50–200 reviews) → reference, optionally adding a reviewCount counter right in the product.
  5. One-to-millions relationship (product → price change history) → reference only. You cannot embed a million records into a document.
  6. Access is needed from both sides (a product knows its category, a category shows its list of products) → reference with an index on categoryId in the products collection.

Antipattern: an unbounded array

A common mistake is to store a list of child objects in the parent document without controlling its growth:

// bad
{
    _id: 1,
    name: "Sweets",
    products: [/* 50,000 objects */]
}

Such a document will hit the 16 MB limit. Long before that — every read of the category loads the whole array, and every product added rewrites the entire document. As the document grows, the storage engine relocates it to a new place and pages get fragmented.

Rule: any array that may grow beyond 100–200 elements or exceed 100 KB in total is better moved to a separate collection through reference.

Bucket pattern: an array with a controlled size

When you need to store a time series — for example, price history — and still keep the convenience of working with an array, use the bucket pattern. Records are grouped N at a time into one document, and once it fills up a new one is created:

{
    productId: 3,
    bucketStart: ISODate("2026-01-01"),
    count: 100,
    prices: [
        { ts: ISODate("2026-01-01T10:00:00Z"), price: 150 },
        { ts: ISODate("2026-01-02T10:00:00Z"), price: 148 }
        // ... 98 more records
    ]
}

The document has a predictable size, the last N values come back in a single query, and there is no runaway growth.

Denormalization: copy for speed

Sometimes the two approaches are combined. You keep a reference and at the same time copy the needed fields into the document to avoid a $lookup on every read:

// product: reference + denormalized category name
{
    _id: 3,
    name: "Candy",
    price: 150,
    categoryId: 1,
    categoryName: "Sweets"   // copied for fast display
}

// category: the single source of truth
{ _id: 1, name: "Sweets", productCount: 3 }

When a category is renamed, a background process walks all products with categoryId = 1 and updates categoryName. If categories are renamed once a month, that is fine. If daily, denormalization brings no benefit.

JSON Schema: structure not just in your head

MongoDB works schemaless by default, but that does not mean there should be no schema. You can attach a JSON Schema validator to a collection — MongoDB will reject documents that do not match the rules:

db.createCollection("product", {
    validator: {
        $jsonSchema: {
            bsonType: "object",
            required: ["name", "price"],
            properties: {
                name:       { bsonType: "string", minLength: 1, maxLength: 200 },
                price:      { bsonType: "number", minimum: 0 },
                categoryId: { bsonType: ["int", "long", "null"] }
            },
            additionalProperties: false
        }
    },
    validationLevel: "strict",
    validationAction: "error"
});

Two levels of strictness:

  • validationLevel: "strict" — all inserts and updates are checked.
  • validationLevel: "moderate" — only documents that already match the schema are checked. Handy when adding new rules to an existing collection without blocking work.

validationAction: "warn" instead of "error" — a violation is logged and the document goes through. Useful during the initial rollout of validation, while the data is not yet in the required shape.

To manage schema versions, store a schemaVersion in each document and bump the version on the next change to the document — this is called "lazy migration".

Indexes

Without indexes MongoDB scans the whole collection on every query. On a collection of 10,000 documents or more this is noticeable. There are six main types of indexes.

Single-field index

db.product.createIndex({ categoryId: 1 });   // ascending
db.product.createIndex({ price: -1 });       // descending

Compound index

Over several fields at once. The order of fields matters — the ESR rule applies: first the Equality fields, then Sort, then Range:

db.product.createIndex({ categoryId: 1, price: -1 });
// works for: find({ categoryId: 1 }).sort({ price: -1 })
// works for: find({ categoryId: 1, price: { $gt: 100 } })
// does not work for: find({ price: { $gt: 100 } }) — the first field is not set

A compound index automatically covers queries on its prefix — a separate { categoryId: 1 } index in that case duplicates the work.

Multikey index

Created automatically when you index an array. Each array element gets its own entry in the index:

// product with tags
{ _id: 3, name: "Candy", tags: ["sweet", "for-kids", "holiday"] }

db.product.createIndex({ tags: 1 });
// find({ tags: "for-kids" }) — uses the index

Important: if each document has 100 elements in the array and there are 10 million documents, the index stores a billion entries. You have to watch its size.

Partial index

Only documents matching a condition are indexed. This saves space and speeds up writes:

// index only active products (90% of the collection is archive)
db.product.createIndex(
    { categoryId: 1 },
    { partialFilterExpression: { active: true } }
);

MongoDB uses such an index only if the query contains the same condition: find({ active: true, categoryId: 1 }).

TTL index

Documents are deleted automatically once time expires. Handy for sessions, logs, temporary data:

db.session.createIndex(
    { createdAt: 1 },
    { expireAfterSeconds: 86400 }  // 24 hours
);

A background process removes documents every 60 seconds, so the precision is to the minute, not to the second.

Unique index

db.product.createIndex({ name: 1 }, { unique: true });
// inserting a duplicate → DuplicateKeyError

In a sharded cluster a unique index works only if the index key includes the shard key — otherwise uniqueness cannot be guaranteed without checking all nodes.

The cost of indexes

Indexes speed up reads but slow down writes. Every INSERT or UPDATE updates all indexes on the affected fields. On a write-heavy collection five indexes mean a fivefold cost for every write.

Indexes also take up disk space (10–30% of the data size for an average index) and RAM — for efficient operation an index should fit in the WiredTiger cache.

Unused indexes are worth dropping. Check usage statistics:

db.product.aggregate([{ $indexStats: {} }]).forEach(s => {
    print(s.name, s.accesses.ops, "ops since", s.accesses.since);
});
// categoryId_1  8,500,000  — in demand
// price_-1      145         — 145 queries per month, a candidate for removal

How to create indexes

Automatically creating indexes at application startup is dangerous in production: building an index on a large collection can slow writes down for hours. The reliable approach is explicit migrations via Mongock or CI scripts run separately from the application deployment. If the framework you use supports automatically creating indexes from model annotations — in production this option is turned off.

How to choose the identifier type

  • ObjectId — the MongoDB standard: 12 bytes, monotonic over time. When sharding by _id, it is better to hash it so the load spreads evenly.
  • UUID — convenient in distributed systems where the identifier is generated without hitting the database. 16 bytes, a bit more space in indexes.
  • Numeric counter — requires an external source (an atomic counter in a counters collection or a separate service). A monotonic counter in a sharded collection is an antipattern: all inserts go into a single key range, creating a hot spot.

In short

  • The main choice is embed or reference. Embed is good for data that is read together. Reference is for data with different change rates and large related collections.
  • The antipattern is an unbounded array in a document. Anything that may grow beyond 100–200 elements is moved to a separate collection.
  • The bucket pattern is a way to store a bounded array for time series.
  • Denormalization (copying a field into a document) speeds up reads but requires updating the copies when the source changes.
  • A JSON Schema on a collection is explicit protection of the structure instead of implicit conventions.
  • Indexes speed up reads but slow down writes and consume memory. Unused indexes are dropped.
  • The ESR rule (Equality → Sort → Range) determines the order of fields in a compound index.
  • Create indexes in production through explicit migrations, not at application startup.
  • ACID and consistency in MongoDB — why proper embedding reduces the need for transactions.
  • Replication and sharding in MongoDB — how document structure affects the cost of sharding.