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.
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: 50003Massive, unbounded arrays
Section titled “Massive, unbounded arrays”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." }The bloated single document
Section titled “The bloated single document”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:
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 outconst 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.
Over-referencing
Section titled “Over-referencing”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:
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 orderdb.customers.findOne({ _id: o.customerId }); // 2: its customerfor (const i of o.items) { const p = db.prods.findOne({ _id: i.productId }); db.categories.findOne({ _id: p.categoryId }); } // 3 products + 3 categoriesdb.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: 1Eight 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 matching
Section titled “Case-insensitive matching”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:
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 collationconsole.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);const people = db.collection("people");await people.insertMany(["Ada", "ada", "ADA", "Grace"].map((name) => ({ name })));await people.createIndex({ name: 1 }, { collation: { locale: "en", strength: 2 }, name: "name_ci" });const plain = await people.find({ name: "ada" }).toArray();const ci = await people.find({ name: "ada" }).collation({ locale: "en", strength: 2 }).toArray();const stage = async (cur) => { const e = await cur.explain("queryPlanner"); let p = e.queryPlanner.winningPlan; while (p.inputStage) p = p.inputStage; return p.stage; };console.log({ plainMatches: plain.length, plainStage: await stage(people.find({ name: "ada" })), ciMatches: ci.length, ciStage: await stage(people.find({ name: "ada" }).collation({ locale: "en", strength: 2 })) });if (plain.length !== 1 || ci.length !== 3) throw new Error("collation behaviour unexpected");from pymongo.collation import Collation
ci = Collation(locale="en", strength=2)db.people.create_index([("name", 1)], collation=ci, name="name_ci")rows = list(db.people.find({"name": "ada"}, collation=ci))ci := &options.Collation{Locale: "en", Strength: 2}_, err := coll.Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "name", Value: 1}}, Options: options.Index().SetName("name_ci").SetCollation(ci),})if err != nil { return err}cur, err := coll.Find(ctx, bson.M{"name": "ada"}, options.Find().SetCollation(ci))if err != nil { return err}var rows []bson.Mif err := cur.All(ctx, &rows); err != nil { return err}let ci = Collation::builder() .locale("en".to_string()) .strength(CollationStrength::Secondary) .build();coll.create_index( IndexModel::builder() .keys(doc! { "name": 1 }) .options( IndexOptions::builder() .name("name_ci".to_string()) .collation(ci.clone()) .build(), ) .build(),).await?;let mut cursor = coll .find(doc! { "name": "ada" }) .collation(ci) .await?;1. regex /^ada$/i, plain index on name : FETCH > IXSCAN(name_1) returned=3 keys=50003 docs=32. index with collation, plain query : COLLSCAN returned=1 keys=0 docs=50003 -> matches: 13. index with collation, SAME collation : FETCH > IXSCAN(name_ci) returned=3 keys=3 docs=3 -> matches: 34. collection default collation, plain : FETCH > IXSCAN(name_1) returned=3 keys=3 docs=3 -> matches: 3{ plainMatches: 1, plainStage: 'COLLSCAN', ciMatches: 3, ciStage: 'IXSCAN'}- The regex used the index but as a full scan of all 50 003 keys to return 3 documents (it cannot seek).
- 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.
- Passing the same collation on the query gives 3 keys, 3 matches.
- A default collation on the collection is the setup where plain queries and their indexes match case-insensitively without remembering the option every time.
Everything in one collection
Section titled “Everything in one collection”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:
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): 60000index KB - mixed collection, plain { email: 1 }: 952 | mixed, partial on type:user: 748 | users-only collection: 748a 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=1Here 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).
The anti-patterns and their fixes
Section titled “The anti-patterns and their fixes”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"]
Tips and gotchas
Section titled “Tips and gotchas”- 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
iscans 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.
Reading AI-generated code
Section titled “Reading AI-generated code”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).
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Approach | Benefit | Cost |
|---|---|---|
| Disciplined modeling up front | Fewer anti-patterns; easier to fix at scale | More design time before writing code |
| Model quickly, fix later | Ships sooner | Migrations under pressure (dual-write, backfill) |
| Collation on the collection | Plain queries are case-insensitive and indexed | All string comparisons in the collection change |
| Collation on one index | Only that index changes | Every query must repeat the collation |
Common Misconceptions
Section titled “Common Misconceptions”- “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$/iread 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.