Skip to content

Anti-patterns

Most bad MongoDB schemas are not bad because someone ignored the rules - they are bad because a reasonable-looking decision aged badly. An embedded array that was fine with ten elements becomes a liability at ten thousand. This final lesson collects the anti-patterns that recur most: how each looks, why it hurts (with numbers where a number exists) and the concrete fix.

Captured on mongo 8.3.11, mongosh 2.11.1 and Node driver 7.7.0 on one Mac; Python, Go and Rust tabs were each run once in a scratch program. The lesson seeds a people collection with 50 003 names for the collation demonstrations; the other sections seed what they need.

mongosh/anti-seed.js
globalThis.plan = (cur) => {
const e = cur.explain("executionStats"), s = e.executionStats, stages = [];
for (let p = e.queryPlanner.winningPlan; p; p = p.inputStage) stages.push(p.stage + (p.indexName ? "(" + p.indexName + ")" : ""));
return stages.join(" > ") + " returned=" + s.nReturned + " keys=" + s.totalKeysExamined + " docs=" + s.totalDocsExamined;
};
const names = ["Ada", "Grace", "Linus", "Margaret", "Edsger"];
db.people.insertMany(Array.from({ length: 50000 }, (_, i) => ({ name: (i % 500 === 0 ? ["ADA", "ada", "Ada"][(i / 500) % 3] : names[i % 5]) + i, city: "c" + (i % 100) })));
db.people.insertMany([{ name: "Ada" }, { name: "ada" }, { name: "ADA" }]);
console.log("people:", db.people.countDocuments());
people: 50003

The most common anti-pattern is embedding an array that grows without limit - every comment on a popular post, every event in a user’s history. The previous lesson measured it: a tiny update went from 1.4 ms to 7.6 ms as the array reached 15 MB and the write failed at push 2 090 with Resulting document after update is larger than 16777216. The fix: move the children to their own collection and reference the parent from each child (with an index), or use the Bucket pattern to cap growth per window. A post keeps a counter, and comments live separately so they can be paged:

{ "_id": "post_310", "title": "Why documents win", "commentCount": 8421 }
{ "_id": "cmt_9", "postId": "post_310", "user": "Grace", "text": "Great read." }

Closely related is the urge to cram everything about an entity into one document - a user with their full activity log inside. A read for just the email still touches a huge document, and the cache holds all of it. 300 users with 1 000 activity events each, against the same users with the events moved out:

mongosh/anti-bloat.js
const event = { at: new Date(), kind: "page_view", url: "/some/path/with/query?x=1", ua: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", tags: ["a", "b", "c"] };
db.usersFat.insertMany(Array.from({ length: 300 }, (_, i) => ({ _id: i, email: "u" + i + "@example.test", activity: Array.from({ length: 1000 }, () => event) })));
db.usersSlim.insertMany(Array.from({ length: 300 }, (_, i) => ({ _id: i, email: "u" + i + "@example.test", activityCount: 1000 })));
db.usersActivity.insertMany(Array.from({ length: 300 }, (_, i) => ({ userId: i, activity: Array.from({ length: 1000 }, () => event) }))); // the cold data, moved out
const size = (c) => { const b = db[c].stats().size; return b > 1048576 ? +(b / 1048576).toFixed(1) + " MB" : Math.round(b / 1024) + " KB"; };
const time = (f) => { const t = Date.now(); f(); return Date.now() - t; };
console.log("data size - fat:", size("usersFat"), "| slim:", size("usersSlim"), "(+ the moved-out activity collection:", size("usersActivity") + ")");
console.log("3 000 reads of just the email (projection { email: 1 }):");
console.log({ fat: time(() => { for (let i = 0; i < 3000; i++) db.usersFat.findOne({ _id: i % 300 }, { email: 1 }); }) + " ms", slim: time(() => { for (let i = 0; i < 3000; i++) db.usersSlim.findOne({ _id: i % 300 }, { email: 1 }); }) + " ms" });
data size - fat: 49.8 MB | slim: 18 KB (+ the moved-out activity collection: 49.8 MB)
3 000 reads of just the email (projection { email: 1 }):
{ fat: '891 ms', slim: '787 ms' }

Warm local reads with a projection differ only modestly (891 ms vs 787 ms for 3 000 reads) - the projection saves the network, not the server’s work - but the resident data is 49.8 MB against 18 KB, and that footprint (cache, I/O, backup) is the lasting cost. The fix: keep the hot fields in the main document and move cold or large sub-data to referenced collections; embedding cold data buys nothing.

The opposite mistake is splitting so aggressively that rendering one screen needs a dozen lookups - the relational join problem again. Order, customer, three products and their categories, counted with the profiler:

mongosh/anti-over-reference.js
db.categories.insertMany([{ _id: "cat_1", name: "Databases" }, { _id: "cat_2", name: "Systems" }]);
db.customers.insertOne({ _id: "cust_77", name: "Grace Hopper", city: "Cambridge" });
db.prods.insertMany([{ _id: "BK-1", title: "Distributed Systems", categoryId: "cat_2" }, { _id: "BK-2", title: "Designing Data-Intensive Applications", categoryId: "cat_1" }, { _id: "BK-3", title: "Compilers", categoryId: "cat_2" }]);
db.ordersRef2.insertOne({ _id: "o1", customerId: "cust_77", items: [{ productId: "BK-1", qty: 1 }, { productId: "BK-2", qty: 2 }, { productId: "BK-3", qty: 1 }] });
db.ordersExt.insertOne({ _id: "o1", customer: { id: "cust_77", name: "Grace Hopper" }, items: [{ productId: "BK-1", title: "Distributed Systems", category: "Systems", qty: 1 }, { productId: "BK-2", title: "Designing Data-Intensive Applications", category: "Databases", qty: 2 }, { productId: "BK-3", title: "Compilers", category: "Systems", qty: 1 }] });
db.setProfilingLevel(2);
const o = db.ordersRef2.findOne({ _id: "o1" }); // 1: the order
db.customers.findOne({ _id: o.customerId }); // 2: its customer
for (const i of o.items) { const p = db.prods.findOne({ _id: i.productId }); db.categories.findOne({ _id: p.categoryId }); } // 3 products + 3 categories
db.setProfilingLevel(0);
const n1 = db.system.profile.countDocuments({ "command.find": { $in: ["ordersRef2", "customers", "prods", "categories"] } });
db.system.profile.drop(); db.setProfilingLevel(2);
db.ordersExt.findOne({ _id: "o1" });
db.setProfilingLevel(0);
const n2 = db.system.profile.countDocuments({ "command.find": "ordersExt" });
console.log("queries to render the order page - fully normalized:", n1, "| embedded snapshot + extended reference:", n2);
assert.strictEqual(n2, 1);
queries to render the order page - fully normalized: 8 | embedded snapshot + extended reference: 1

Eight queries against one. The fix: embed data that is genuinely read together and bounded, or use the Extended Reference pattern to copy the one or two fields you always display (title, category, customer name), so the common read stops fanning out. Snapshots on an order are also historically correct, as the embedding lesson showed.

Case-insensitive lookups ("Ada", "ada", "ADA") tempt people to a regex like /^ada$/i. The old lesson said a collation “on the collection or index” makes ordinary equality queries case-insensitive and indexed. Half of that is wrong. Four setups on 50 003 names, three of which match ada in any case:

mongosh/anti-collation.js
console.log("1. regex /^ada$/i, plain index on name :");
db.people.createIndex({ name: 1 });
console.log(" ", plan(db.people.find({ name: /^ada$/i })));
db.people.dropIndex("name_1");
db.people.createIndex({ name: 1 }, { collation: { locale: "en", strength: 2 }, name: "name_ci" });
console.log("2. index with collation, plain query :", plan(db.people.find({ name: "ada" })), "-> matches:", db.people.find({ name: "ada" }).toArray().length);
console.log("3. index with collation, SAME collation :", plan(db.people.find({ name: "ada" }).collation({ locale: "en", strength: 2 })), "-> matches:", db.people.find({ name: "ada" }).collation({ locale: "en", strength: 2 }).toArray().length);
db.createCollection("peopleCI", { collation: { locale: "en", strength: 2 } });
db.peopleCI.insertMany(db.people.find({}, { _id: 0 }).toArray());
db.peopleCI.createIndex({ name: 1 }); // inherits the collection collation
console.log("4. collection default collation, plain :", plan(db.peopleCI.find({ name: "ada" })), "-> matches:", db.peopleCI.find({ name: "ada" }).toArray().length);
assert.strictEqual(db.peopleCI.find({ name: "ada" }).toArray().length, 3);
1. regex /^ada$/i, plain index on name :
FETCH > IXSCAN(name_1) returned=3 keys=50003 docs=3
2. index with collation, plain query : COLLSCAN returned=1 keys=0 docs=50003 -> matches: 1
3. index with collation, SAME collation : FETCH > IXSCAN(name_ci) returned=3 keys=3 docs=3 -> matches: 3
4. collection default collation, plain : FETCH > IXSCAN(name_1) returned=3 keys=3 docs=3 -> matches: 3
{
plainMatches: 1,
plainStage: 'COLLSCAN',
ciMatches: 3,
ciStage: 'IXSCAN'
}
  1. The regex used the index but as a full scan of all 50 003 keys to return 3 documents (it cannot seek).
  2. An index with a collation is used only by queries that carry the same collation. The plain query was a COLLSCAN and matched just the exact-case document.
  3. Passing the same collation on the query gives 3 keys, 3 matches.
  4. A default collation on the collection is the setup where plain queries and their indexes match case-insensitively without remembering the option every time.

The instinct to dump unrelated entities into one collection distinguished by a type field makes indexes cover documents they do not care about and validators unable to describe one shape. A plain index on email also stores an entry for every document that lacks the field:

mongosh/anti-one-collection.js
const kb = (c, n) => Math.round(db[c].stats().indexSizes[n] / 1024);
db.everything.insertMany(Array.from({ length: 30000 }, (_, i) => ({ type: "user", email: "u" + i + "@example.test", name: "User " + i })));
db.everything.insertMany(Array.from({ length: 30000 }, (_, i) => ({ type: "order", total: i % 100, userId: i % 30000 })));
db.everything.insertMany(Array.from({ length: 30000 }, (_, i) => ({ type: "log", level: "info", message: "event " + i })));
db.users2.insertMany(Array.from({ length: 30000 }, (_, i) => ({ email: "u" + i + "@example.test", name: "User " + i })));
db.everything.createIndex({ email: 1 });
db.everything.createIndex({ email: 1 }, { name: "email_partial", partialFilterExpression: { type: "user" } });
db.users2.createIndex({ email: 1 });
console.log("documents:", db.everything.countDocuments(), "| with an email:", db.everything.countDocuments({ email: { $exists: true } }), "| without one (indexed as null by a plain index):", db.everything.countDocuments({ email: { $exists: false } }));
console.log("index KB - mixed collection, plain { email: 1 }:", kb("everything", "email_1"), "| mixed, partial on type:user:", kb("everything", "email_partial"), "| users-only collection:", kb("users2", "email_1"));
console.log("a lookup for a missing email on the plain index does an index seek into the null entries:", plan(db.everything.find({ email: null }).limit(1)));
documents: 90000 | with an email: 30000 | without one (indexed as null by a plain index): 60000
index KB - mixed collection, plain { email: 1 }: 952 | mixed, partial on type:user: 748 | users-only collection: 748
a lookup for a missing email on the plain index does an index seek into the null entries: LIMIT > FETCH > IXSCAN(email_1) returned=1 keys=1 docs=1

Here the waste is modest (952 KB vs 748 KB) but it scales with the number of unrelated documents. The fix: one collection per entity, or at least partial indexes filtered by type. The opposite extreme is also a trap: Atlas lists “Reduce Number of Collections” among its schema suggestions, so split by entity, not by tenant or by day (Atlas schema suggestions, consulted 2026-09-30).

flowchart LR
  A1["Massive unbounded array"] --> F1["Reference children or use the Bucket pattern"]
  A2["Bloated single document"] --> F2["Keep hot fields, reference cold and large data"]
  A3["Over-referencing everywhere"] --> F3["Embed co-accessed data or extend the reference"]
  A4["Case-insensitive via regex"] --> F4["Collection default collation, or pass the index collation"]
  A5["Everything in one collection"] --> F5["One entity per collection"]
Each modeling anti-pattern maps to a concrete fix drawn from earlier lessons
  • Anti-patterns rarely look wrong on day one; review schemas with growth in mind.
  • “Unbounded array” and “bloated document” are two faces of embedding data that should have been referenced.
  • Over-referencing is the over-correction: 8 queries for one page. Decide per relationship.
  • For case-insensitive matching use a collection default collation, or give the query the index’s collation; a regex with i scans the whole index.
  • MongoDB Atlas surfaces several of these (unbounded arrays, bloated documents, too many $lookups, too many collections, unnecessary indexes) in the Performance Advisor, available on M10+ clusters, and in Compass Performance Insights.

Asked to “make name search case-insensitive and fast”, an assistant creates a case-insensitive index and queries as before:

🐛 Spot the bug

// AI comment: "the index is case-insensitive, so this find is too - and it uses the index"
await people.createIndex({ name: 1 }, { collation: { locale: "en", strength: 2 } });
const rows = await people.find({ name: "ada" }).toArray();
Show the answer

A query uses a collation-bearing index only when it specifies the same collation. Without it the find above is a COLLSCAN over all 50 003 documents that matches just ada in exact case (1 match, not 3) - wrong results and slow, with no error. Either add .collation({ locale: "en", strength: 2 }) to every query, or (better) create the collection with that default collation; the fence output above shows both working (IXSCAN, 3 matches).

An anti-pattern is a decision whose cost grows with data while its benefit stays flat: the array that grows, the document that carries cold weight, the query that scans a whole index. Ask of each design choice “what happens at 100x the data?” - the answer is either a number that stays flat or a number that explains next year’s incident.

ApproachBenefitCost
Disciplined modeling up frontFewer anti-patterns; easier to fix at scaleMore design time before writing code
Model quickly, fix laterShips soonerMigrations under pressure (dual-write, backfill)
Collation on the collectionPlain queries are case-insensitive and indexedAll string comparisons in the collection change
Collation on one indexOnly that index changesEvery query must repeat the collation
  • “Anti-patterns are beginner mistakes.” - most were reasonable at design time and degraded as data grew.
  • “A collation on an index makes plain queries case-insensitive.” - the plain query was a COLLSCAN with 1 match.
  • “A regex on an indexed field is fine.” - /^ada$/i read all 50 003 keys.
  • “Collections are free, so split without limit.” - Atlas flags too many collections; split by entity.
  • “Fix a schema by deploying a change.” - moving an embedded array to a reference on live data needs a staged rollout (dual-write, then backfill).

Real-world example

Atlas turns these into product features: the Performance Advisor and Compass Performance Insights “identify schema design anti-patterns in your database” - unbounded arrays, bloated documents, too many $lookups, too many collections and unnecessary indexes (schema suggestions, consulted 2026-09-30; Performance Advisor needs an M10+ cluster).

Python/Go/Rust tabs mirror the verified shell/Node behaviour; each was run once in a scratch program against the same server (the collation-carrying query returned all three casings) but they are not executed by the course’s automated checker.

You create an index with collation { locale: "en", strength: 2 } and then run find({ name: "ada" }) with no collation. What happens?
Which setup lets ordinary find({ name: "ada" }) match case-insensitively AND use the index?
Why is /^ada$/i on a plain index still slow?
Rendering an order page needs the order, its customer, 3 products and their 3 categories. How many queries in the fully normalized model?
What is the main cost of a bloated document that holds cold activity data next to the hot fields?
Why avoid one collection with a type field for everything?