Chapter 71 β Data Modelling & Schema Design Patterns
π Definition
MongoDB data modelling means designing documents around how the application reads and writes data (access patterns), not around normalised tables. The golden rule:
Data that is accessed together should be stored together.
This is the topic that separates junior from senior MongoDB answers β most "MongoDB is slow" stories are really modelling mistakes.
π The Modelling Process
- List the workload β the most frequent queries and writes, with rough volumes ("show order with items: 5k/s", "add item: 50/s").
- Map relationships β 1-1, 1-few, 1-many, 1-squillions, many-to-many.
- Decide embed vs reference for each relationship.
- Apply patterns (below) for growth, performance and versioning.
- Add indexes that match the queries.
π Embed vs Reference
| Embed (sub-document / array) when⦠| Reference (store _id) when⦠|
|---|---|
| Child is read with the parent almost always | Child is often read on its own |
| Child doesn't make sense alone (address, line items) | Child is shared by many parents (product in many orders) |
| Number of children is small and bounded | Number of children is large or unbounded |
| Child changes rarely, or only with parent | Child changes often and independently |
| You need atomic updates across parent + child | Document would approach the 16 MB limit |
π» Relationship Examples
One-to-One β embed
// users
{ _id: 1, name: "Asha", profile: { bio: "β¦", avatarUrl: "β¦", dob: ISODate("1998-04-02") } }One-to-Few β embed an array
{ _id: 1, name: "Asha", addresses: [ { label: "home", city: "Pune" }, { label: "work", city: "Mumbai" } ] }One-to-Many β reference from the child (or array of ids in the parent if bounded)
// products
{ _id: "P1", name: "Phone", partIds: ["X1", "X2", "X3"] } // bounded list of ids in parent
// orders β reference the customer (child holds parent id)
{ _id: "O1", customerId: 1, total: 1200 }
db.orders.createIndex({ customerId: 1, createdAt: -1 });One-to-Squillions β child references parent, NEVER an array in the parent
// Logs per server: millions β array would hit 16 MB and get slower on every push
{ _id: ObjectId(), serverId: "srv-1", level: "error", msg: "β¦", at: ISODate() }
db.logs.createIndex({ serverId: 1, at: -1 });Many-to-Many β array of references on one or both sides
// students β courses
{ _id: "S1", name: "Asha", courseIds: ["C1", "C2"] }
{ _id: "C1", title: "Databases" } // query students of C1: db.students.find({ courseIds: "C1" }) + indexπ» Pattern β Extended Reference (Embed the Fields You Need)
// Instead of $lookup on every order list, copy the few customer fields shown in the UI
{
_id: "O1",
customer: { _id: 1, name: "Asha", city: "Pune" }, // duplicated, read-mostly fields
items: [ { sku: "A1", name: "Pen", price: 20, qty: 2 } ],
total: 40,
}Trade-off: if the customer renames, you update copies (background job / change stream) β or accept the snapshot (an order should keep the price and address at the time of purchase).
π» Pattern β Subset (Keep Hot Data Small)
// products: embed only the 10 most recent reviews; full reviews live in their own collection
{ _id: "P1", name: "Phone", reviewCount: 2381, avgRating: 4.4,
recentReviews: [ /* last 10 */ ] }
db.products.updateOne({ _id: "P1" }, {
$push: { recentReviews: { $each: [review], $sort: { at: -1 }, $slice: 10 } },
$inc: { reviewCount: 1 },
});
db.reviews.insertOne({ productId: "P1", ...review });Keeps the working set in RAM small β product pages load one small document.
π» Pattern β Computed (Pre-Aggregate on Write)
// Instead of counting likes on every read:
db.posts.updateOne({ _id: postId }, { $inc: { likeCount: 1 } });
db.likes.insertOne({ postId, userId, at: new Date() }); // unique index { postId, userId }Good when reads vastly outnumber writes (feeds, dashboards, leaderboards).
π» Pattern β Bucket (Time-Series / IoT)
// One document per sensor per hour instead of one per reading
{
sensorId: "s-42",
hour: ISODate("2026-10-22T10:00:00Z"),
count: 3,
sum: 75.1,
readings: [ { t: ISODate("β¦10:00:05Z"), v: 25.0 }, { t: "β¦", v: 25.1 }, { t: "β¦", v: 25.0 } ],
}
db.sensorData.updateOne(
{ sensorId: "s-42", hour: hourStart, count: { $lt: 200 } }, // cap bucket size
{ $push: { readings: { t: now, v } }, $inc: { count: 1, sum: v } },
{ upsert: true },
);Fewer documents and index entries, pre-computed sums. Since MongoDB 5.0, time series collections apply this pattern automatically (Advanced Features chapter).
π» Pattern β Outlier
// 99.9% of books have < 1000 buyers; a bestseller has millions.
{ _id: "B1", title: "Normal book", buyerIds: [/* β€ 1000 */] }
{ _id: "B2", title: "Bestseller", buyerIds: [/* first 1000 */], hasOverflow: true }
// overflow docs: { bookId: "B2", page: 2, buyerIds: [/* next 1000 */] }Optimise the model for the common case; handle the rare huge case separately.
π» Pattern β Attribute (Many Similar, Sparse Fields)
// β Needs an index per field, most documents lack most fields
{ name: "Shirt", color: "red", size: "M", sleeve: "half" }
// β
One index serves every attribute query
{ name: "Shirt", attrs: [ { k: "color", v: "red" }, { k: "size", v: "M" }, { k: "sleeve", v: "half" } ] }
db.products.createIndex({ "attrs.k": 1, "attrs.v": 1 });
db.products.find({ attrs: { $elemMatch: { k: "color", v: "red" } } });π» Pattern β Polymorphic & Schema Versioning
// Different shapes in one collection, distinguished by a type field (Mongoose: discriminators)
{ _id: 1, type: "card", last4: "4242", brand: "visa" }
{ _id: 2, type: "upi", vpa: "asha@bank" }
// Schema versioning: migrate lazily instead of a big-bang migration
{ _id: 1, schemaVersion: 2, name: { first: "Asha", last: "R" } }
{ _id: 2, name: "Ravi K" } // v1 β app converts on read and rewrites on next saveβ οΈ Anti-Patterns
| Anti-pattern | Why it hurts | Fix |
|---|---|---|
| Unbounded arrays (comments, followers, logs in parent) | Document grows toward 16 MB; every update rewrites a big doc; index on array grows | Reference from child; subset/bucket |
| Massive number of collections (one per user/tenant) | Each collection + index costs files and RAM | One collection with a tenantId field + index |
| Unnecessary indexes | Slow writes, RAM usage | Remove unused ($indexStats) |
| Bloated documents (rarely used big fields in hot docs) | Working set doesn't fit RAM | Move cold data to another collection |
| Separating data accessed together | Many $lookups / round trips |
Embed or extended reference |
| Case-insensitive regex queries | Can't use index efficiently | Collation index or store a lowercased field |
π» Worked Example β E-commerce Model
// users { _id, name, email (unique), addresses: [ {β¦} ] } embed few addresses
// products { _id, name, price, stock, categoryIds: [], attrs: [ {k,v} ],
// avgRating, reviewCount, recentReviews: [ β€10 ] } subset + computed
// reviews { _id, productId, userId, rating, text, at } 1-to-squillions
// carts { _id: userId, items: [ { productId, qty, priceAtAdd } ], updatedAt } one doc per user
// orders { _id, userId, status, items: [ { productId, name, price, qty } ],
// shippingAddress: { β¦ }, total, createdAt } snapshot (extended reference)
// Indexes
db.users.createIndex({ email: 1 }, { unique: true });
db.orders.createIndex({ userId: 1, createdAt: -1 });
db.reviews.createIndex({ productId: 1, at: -1 });
db.products.createIndex({ categoryIds: 1, price: 1 });Why order items embed product name and price: an order must show what the customer paid, even if the product changes or is deleted later.
π Real-World Impact
- A chat app that stored messages in an array inside each conversation hit the 16 MB limit and slow writes; moving messages to their own collection with
{ conversationId, createdAt }index fixed it. - Extended-reference + computed fields removed 3
$lookups from a product list page, cutting p95 latency by 5x.
π― Likely Interview Questions
- How do you decide between embedding and referencing? β Access patterns, cardinality (few/many/squillions), independent access, update frequency, 16 MB limit.
- What is the document size limit? β 16 MB per document (use GridFS for files).
- Why are unbounded arrays bad? β Unlimited growth, slow updates, large index entries, 16 MB limit.
- How do you model one-to-millions? β Child documents hold the parent id; index it.
- What is denormalisation and its cost? β Duplicating data for read speed; cost is keeping copies in sync (or accepting snapshots).
- Explain the bucket pattern. β Group many small time-based readings into one doc per time window with counts/sums.
- What is the subset pattern? β Keep the frequently used part (e.g. latest 10 reviews) in the main doc; the rest elsewhere.
- How do you handle schema changes in MongoDB? β
schemaVersionfield + lazy migration on read/write, or background migration scripts. - Design the schema for Twitter/Instagram followers. β
followscollection{ followerId, followeeId, at }with unique compound index + both-direction indexes; storefollowerCount/followingCountas computed fields. - Design a schema for a blog with comments. β Posts with
commentCount+ latest few comments (subset); comments in their own collection indexed by{ postId, createdAt }. - How do you model multi-tenant data? β Shared collections with
tenantIdin every document, first field in every index, enforced in the data layer.
β CRUD & Query Operators | Index | Next: Mongoose Schemas β