Data Modeling Anti-Patterns
MongoDB’s flexible, schema-less documents are powerful, but that same flexibility makes it easy to design a schema that looks fine with ten test documents and falls apart with ten million real ones. A data modeling anti-pattern is a design choice that seems natural at first but leads to oversized documents, slow queries, or documents that silently stop working once they cross a hard limit. This lesson walks through the most common anti-patterns MongoDB engineers run into in production, why each one causes real problems, and the corrected design for each.
Overview: How Schema Design Decisions Play Out at Scale
Unlike a relational database, MongoDB does not force you to normalize data into separate tables joined at query time. You choose, per relationship, whether to embed related data inside a single document or reference it from a separate collection. That choice is the single biggest lever in MongoDB schema design, and every anti-pattern in this lesson is really a variation of getting that choice wrong.
A few physical facts about how MongoDB stores documents explain why these mistakes matter:
- Every BSON document has a hard 16MB size limit. A design that keeps appending data to one document (comments, log events, order history) will eventually hit that ceiling and start throwing errors.
- When a document grows past the space WiredTiger originally allocated for it, the storage engine has to move and rewrite it. Frequent, unbounded growth (for example, repeatedly
$push-ing into an array) causes extra write amplification over time. - A collection scan (
COLLSCAN) reads every document in a collection. Wide, bloated documents make every scan more expensive because more bytes must be read off disk and pulled into the WiredTiger cache, even if only a couple of fields matter for a given query. $lookupperforms a join-like operation per input document; run it against a collection with no supporting index, or against a working set too large to fit in the index and cache, and it degrades badly as the input grows.
With that physical model in mind, the anti-patterns below all reduce to one root cause: data that is shaped for how it was written, not for how it is actually read and how large it will grow.
Syntax: The Embed-vs-Reference Decision
There is no single MongoDB operator for “correct” data modeling — it is a design decision you make per relationship. The general framework looks like this:
| Question | Favors embedding | Favors referencing |
|---|---|---|
| Is the related data read together with the parent, almost every time? | Yes | No |
| Does the related data grow without a predictable bound? | No (bounded, small) | Yes (comments, logs, events) |
| Is the related data shared/read by many different parent documents? | No | Yes (a product referenced by thousands of orders) |
| Does the related data change independently of the parent? | No | Yes |
You can also enforce a shape with a JSON Schema validator on the collection, which is the closest MongoDB gets to a table definition and is worth using once a schema has stabilized:
db.createCollection("orders", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["customer", "items", "status"],
properties: {
customer: { bsonType: "string" },
status: { enum: ["pending", "shipped", "delivered", "cancelled"] },
items: {
bsonType: "array",
maxItems: 200
}
}
}
}
});
Notice the maxItems: 200 constraint on items — a validator is also a good place to put a hard cap on array size, which forces the unbounded-array anti-pattern below to surface immediately in development instead of six months later in production.
Examples
Example 1: The Unbounded Array Anti-Pattern
A blog application embeds comments directly inside each post so they load in one query. This works great in a demo:
db.posts.insertOne({
title: "Understanding MongoDB Indexes",
author: "Priya Sharma",
body: "An index is a data structure that...",
comments: []
});
Output:
{
acknowledged: true,
insertedId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1")
}
Every new comment is appended with $push:
db.posts.updateOne(
{ _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") },
{ $push: { comments: { user: "dev_amit", text: "Great explanation!", createdAt: new Date() } } }
);
For a niche blog this never becomes a problem. For a popular post on a high-traffic site, the comments array grows without any bound tied to real-world constraints — a viral post can accumulate tens of thousands of comments, pushing the document toward the 16MB limit and making every read of the post (even one that only needs the title) pull megabytes of comment data off disk.
Example 2: The Massive Number of Collections Anti-Pattern
A multi-tenant SaaS app gives each customer their own collection to “isolate” their data:
show collections
Output:
tenant_1001_orders
tenant_1002_orders
tenant_1003_orders
tenant_1004_orders
... (thousands more)
This looks like isolation, but WiredTiger keeps per-collection metadata, and each index on each collection consumes its own memory in the cache. Thousands of tiny collections (and their indexes) fragment the storage engine’s internal cache far more than one large, well-indexed collection would, and it makes cross-tenant operations (analytics, migrations, admin tooling) require iterating over every collection name instead of running one query.
Example 3: The Duplicated Subdocument Anti-Pattern
An orders system embeds the full product document inside every order line item so the order page never needs a join:
db.orders.insertOne({
customer: "acme-corp",
items: [
{
productId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d2"),
name: "Wireless Mouse",
description: "Ergonomic wireless mouse, 2.4GHz, 1600 DPI, includes AA batteries and 12-month warranty.",
category: "Electronics > Accessories > Mice",
manufacturer: "Acme Peripherals Co.",
price: 24.99,
quantity: 3
}
]
});
Output:
{ acknowledged: true, insertedId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d3") }
Two problems appear as the catalog grows. First, every order duplicates the full product description and metadata, so a document that could be a few hundred bytes becomes several kilobytes multiplied across every order ever placed. Second, when the product’s description or category is corrected, every past order still shows the old, now-wrong data — because it was copied, not referenced, there is no single source of truth to update.
How It Works Step by Step
To see why the fixed designs behave better, look at what MongoDB actually does for each corrected pattern:
Fixing the unbounded array (Example 1): move comments into their own collection, referencing the post by _id, and index the reference field:
db.comments.insertOne({
postId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
user: "dev_amit",
text: "Great explanation!",
createdAt: new Date()
});
db.comments.createIndex({ postId: 1, createdAt: -1 });
Now the post document stays small and stable forever, and fetching a page of comments is an IXSCAN on postId instead of scanning a growing embedded array. Fetching the post and its first page of comments is two indexed queries instead of one document read that keeps getting heavier.
Fixing the massive-collection pattern (Example 2): use one orders collection with a tenantId field and a compound index that leads with equality on the tenant, following the Equality-Sort-Range (ESR) rule:
db.orders.createIndex({ tenantId: 1, createdAt: -1 });
db.orders.find({ tenantId: "acme-corp", status: "shipped" }).sort({ createdAt: -1 });
The query planner uses the compound index to jump straight to acme-corp‘s documents and walk them in already-sorted order — the same performance benefit the per-tenant collections were chasing, without the metadata and cache overhead of thousands of collections.
Fixing duplicated subdocuments (Example 3): store only a reference plus the few fields that must be preserved at the moment of purchase (a price snapshot is legitimate — it should not change retroactively), and $lookup the rest only when a query actually needs it:
db.orders.insertOne({
customer: "acme-corp",
items: [
{ productId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d2"), name: "Wireless Mouse", priceAtPurchase: 24.99, quantity: 3 }
]
});
db.orders.aggregate([
{ $match: { customer: "acme-corp" } },
{ $lookup: {
from: "products",
localField: "items.productId",
foreignField: "_id",
as: "productDetails"
} }
]).explain("executionStats");
Running .explain("executionStats") on a pipeline like this shows whether the $lookup stage used an index on products._id (it will, since _id is always indexed) versus a foreign field you added later without an index, which forces a collection scan per matched order.
Common Mistakes
Mistake: comparing an ObjectId to a plain string. A URL parameter or JSON body always arrives as a string, but _id is stored as a BSON ObjectId:
// Wrong: req.params.id is the string "64f1a2b3c4d5e6f7a8b9c0d1", never equal to a stored ObjectId
db.users.findOne({ _id: req.params.id }); // matches nothing
Convert it explicitly before querying:
db.users.findOne({ _id: new ObjectId(req.params.id) });
Mistake: using updateOne when every matching document should change. This silently discounts one product instead of the whole clearance category:
// Wrong: only the single first-matched document is updated
db.products.updateOne({ category: "clearance" }, { $set: { onSale: true } });
db.products.updateMany({ category: "clearance" }, { $set: { onSale: true } });
Mistake: adding an index for every field you ever query on. Each index has to be updated on every write and consumes RAM in the cache; a collection with fifteen single-field indexes will have noticeably slower writes and a bloated working set. Build compound indexes that match your actual query patterns instead of one index per field.
Best Practices
- Model for your application’s read patterns first — ask “what does the most common query need to load?” before deciding what to embed.
- Treat any array that could grow with user activity (comments, events, log entries, notifications) as a candidate for its own referenced collection, not an embedded array.
- Set a
$jsonSchemavalidator with amaxItemsor size constraint on arrays that must stay bounded, so growth problems fail loudly in development. - Use one collection per entity type with a discriminating field (like
tenantId), not one collection per tenant, customer, or shard of logically identical data. - Only duplicate data across documents when it is a deliberate, immutable snapshot (a price at time of purchase); never duplicate data you expect to stay in sync elsewhere.
- Run
.explain("executionStats")on your important queries and aggregation pipelines regularly, especially any pipeline containing$lookup, to confirm indexes are actually being used. - Revisit your schema as access patterns change — a design that was correct at launch can become an anti-pattern once a feature (like unlimited comments) becomes popular.
Practice Exercises
- You have a
db.videoscollection where each document embeds aviewsarray logging every single view event (user, timestamp). Redesign this so view counts stay fast to read and the video document never grows unbounded. Hint: separate the raw event log from a maintained counter field. - Given
db.ordersdocuments that embed a full, duplicatedcustomersubdocument (name, address, email) inside every order, write the referencing version plus the index you would add to make looking up all orders for a customer efficient. - You inherit a database with one collection per calendar month (
events_2026_01,events_2026_02, …). Describe the single-collection redesign and the compound index that would let you query “all events for March 2026” as efficiently as the old per-month collection did.
Summary
- Anti-patterns almost always come from embedding data that grows unbounded, or from splitting data that is always read together into too many collections.
- Unbounded arrays risk hitting the 16MB document limit and make every read of the parent document heavier as the array grows.
- One collection per tenant/customer/time-period fragments the storage engine’s cache; prefer one collection with a well-indexed discriminating field.
- Duplicating full subdocuments causes stale data; duplicate only deliberate, immutable snapshots, and reference everything else.
- Always verify your fixes with
.explain("executionStats")— a redesigned schema is only better if the query plan proves it.
