Data modeling
If you arrive at MongoDB from a relational background, the hardest habit to unlearn is the one that served you best in SQL: splitting your data into many narrow tables joined back together at query time. That discipline is normalization; it removes duplication so a fact lives in exactly one place. MongoDB asks you to think the other way around. You start from “what does my application ask for, and how can a single read hand back everything one screen needs.”
The unit of storage is the document - a nested, JSON-like structure with arrays and sub-objects. Because a document can hold related data inside itself, the question behind every modeling decision is: “do I nest this related data inside the document, or keep it separate and store a reference?” That choice - embedding versus referencing - is what this module turns on.
Datasets in this module. Each lesson seeds its own data with the same shape: products (_id: "BK-n", title, price) and orders in two versions - ordersEmb (items carry a copy of the title and price) and ordersRef (items carry only productId). This lesson uses 500 products and 10 000 orders of 2-5 items. 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.
const rnd = (() => { let x = 7; return () => (x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 4294967296; })(); // repeatable pseudo-random numbersdb.products.insertMany(Array.from({ length: 500 }, (_, i) => ({ _id: "BK-" + i, title: "Book " + i, price: 10 + (i % 40) })));const specs = Array.from({ length: 10000 }, (_, i) => ({ _id: "order_" + i, customerId: "cust_" + (i % 500), status: i % 3 ? "shipped" : "new", lines: Array.from({ length: 2 + Math.floor(rnd() * 4) }, () => { const p = Math.floor(rnd() * 500); return { p, qty: 1 + Math.floor(rnd() * 3) }; }),}));db.ordersEmb.insertMany(specs.map(({ lines, ...o }) => ({ ...o, items: lines.map((l) => ({ sku: "BK-" + l.p, title: "Book " + l.p, qty: l.qty, price: 10 + (l.p % 40) })) }))); // embedded copiesdb.ordersRef.insertMany(specs.map(({ lines, ...o }) => ({ ...o, items: lines.map((l) => ({ productId: "BK-" + l.p, qty: l.qty })) }))); // references onlyconsole.log("products:", db.products.countDocuments(), "| orders:", db.ordersEmb.countDocuments(), "embedded /", db.ordersRef.countDocuments(), "referenced");products: 500 | orders: 10000 embedded / 10000 referencedRelational habits versus document thinking
Section titled “Relational habits versus document thinking”In a relational schema you design around the data’s shape and let queries pay the cost of reassembling it. In a document schema you design around the queries and decide duplication up front:
flowchart TB
subgraph Relational["Relational - model for storage"]
R0["Start: list the entities"] --> R1["Split into normalized tables"]
R1 --> R2["No duplication - one fact, one place"]
R2 --> R3["Reassemble with JOINs at read time"]
end
subgraph Document["Document - model for access"]
D0["Start: list the queries"] --> D1["Group data that is read together"]
D1 --> D2["Embed or reference based on access"]
D2 --> D3["One read returns a whole screen"]
end Neither is universally correct. The relational instinct is excellent when data is highly interrelated and updated from many directions; the document instinct shines when an application repeatedly fetches the same shaped bundle - a user and settings, an order and its line items, an article and its tags.
A worked example: an order, measured
Section titled “A worked example: an order, measured”The same order in both shapes, then the numbers. The embedded document is everything an “order details” page needs; the referenced one is smaller but needs the products collection to become readable. The Node.js tab counts real query round trips with the database profiler; the Python, Go and Rust tabs show the referenced read as its two queries:
const one = (c) => db[c].findOne({ _id: "order_42" });console.log("order_42 embedded :", JSON.stringify(one("ordersEmb")));console.log("order_42 referenced:", JSON.stringify(one("ordersRef")));const avg = (c) => Math.round(db[c].aggregate([{ $group: { _id: null, b: { $avg: { $bsonSize: "$$ROOT" } } } }]).toArray()[0].b);console.log("average document bytes - embedded:", avg("ordersEmb"), "| referenced:", avg("ordersRef"), "| product:", avg("products"));const mb = (c) => +(db[c].stats().size / 1048576).toFixed(2);console.log("collection data MB - embedded:", mb("ordersEmb"), "| referenced:", mb("ordersRef"), "+ products", mb("products"));assert.ok(avg("ordersEmb") > avg("ordersRef"));const products = db.collection("products"), emb = db.collection("ordersEmb"), ref = db.collection("ordersRef");await products.insertMany([{ _id: "BK-1", title: "Distributed Systems", price: 42 }, { _id: "BK-2", title: "The Mythical Man-Month", price: 18.5 }, { _id: "BK-3", title: "Compilers", price: 55 }]);await emb.insertOne({ _id: "o1", items: [{ sku: "BK-1", title: "Distributed Systems", qty: 1, price: 42 }, { sku: "BK-2", title: "The Mythical Man-Month", qty: 2, price: 18.5 }, { sku: "BK-3", title: "Compilers", qty: 1, price: 55 }] });await ref.insertOne({ _id: "o1", items: [{ productId: "BK-1", qty: 1 }, { productId: "BK-2", qty: 2 }, { productId: "BK-3", qty: 1 }] });const count = async (label, fn) => { await db.command({ profile: 0 }); await db.collection("system.profile").drop().catch(() => {}); // start each measurement with an empty profile await db.command({ profile: 2 }); // record every operation on THIS database await fn(); await db.command({ profile: 0 }); const n = await db.collection("system.profile").countDocuments({ op: { $in: ["query", "command"] }, "command.find": { $exists: true } }); console.log(label.padEnd(32), n, "query round trip(s)"); return n;};const a = await count("embedded order", () => emb.findOne({ _id: "o1" }));const b = await count("referenced, one query per item", async () => { const o = await ref.findOne({ _id: "o1" }); for (const i of o.items) await products.findOne({ _id: i.productId }); });const c = await count("referenced, $in", async () => { const o = await ref.findOne({ _id: "o1" }); await products.find({ _id: { $in: o.items.map((i) => i.productId) } }).toArray(); });await db.command({ profile: 0 });if (a !== 1 || b !== 4 || c !== 2) throw new Error("unexpected round-trip counts " + [a, b, c]);order = db.ordersRef.find_one({"_id": "order_42"})ids = [i["productId"] for i in order["items"]]products = list(db.products.find({"_id": {"$in": ids}}))var order struct { Items []struct { ProductID string `bson:"productId"` Qty int `bson:"qty"` } `bson:"items"`}if err := coll.FindOne(ctx, bson.M{"_id": "order_42"}).Decode(&order); err != nil { return err}ids := bson.A{}for _, it := range order.Items { ids = append(ids, it.ProductID)}cur, err := db.Collection("products").Find(ctx, bson.M{"_id": bson.M{"$in": ids}})if err != nil { return err}var products []bson.Mif err := cur.All(ctx, &products); err != nil { return err}let order = coll.find_one(doc! { "_id": "order_42" }).await?.unwrap();let ids: Vec<_> = order .get_array("items")? .iter() .map(|i| i.as_document().unwrap().get_str("productId").unwrap().to_string()) .collect();let mut cursor = db .collection::<Document>("products") .find(doc! { "_id": { "$in": ids } }) .await?;order_42 embedded : {"_id":"order_42","customerId":"cust_42","status":"new","items":[{"sku":"BK-481","title":"Book 481","qty":2,"price":11},{"sku":"BK-375","title":"Book 375","qty":1,"price":25},{"sku":"BK-154","title":"Book 154","qty":1,"price":44},{"sku":"BK-319","title":"Book 319","qty":3,"price":49},{"sku":"BK-295","title":"Book 295","qty":1,"price":25}]}order_42 referenced: {"_id":"order_42","customerId":"cust_42","status":"new","items":[{"productId":"BK-481","qty":2},{"productId":"BK-375","qty":1},{"productId":"BK-154","qty":1},{"productId":"BK-319","qty":3},{"productId":"BK-295","qty":1}]}average document bytes - embedded: 303 | referenced: 216 | product: 52collection data MB - embedded: 2.89 | referenced: 2.06 + products 0.02embedded order 1 query round trip(s)referenced, one query per item 4 query round trip(s)referenced, $in 2 query round trip(s)Embedding costs about 40% more bytes per order (303 vs 216 on average, 2.89 MB vs 2.06 MB for the collection) because each item repeats a title and price - copies that can drift from products. What it buys is a read that needs one query. Timing 2 000 order-detail reads on the local server:
const ids = Array.from({ length: 2000 }, (_, i) => "order_" + ((i * 37) % 10000));const time = (f) => { const t = Date.now(); f(); return Date.now() - t; };const emb = time(() => ids.forEach((id) => db.ordersEmb.findOne({ _id: id })));const ref = time(() => ids.forEach((id) => { const o = db.ordersRef.findOne({ _id: id }); db.products.find({ _id: { $in: o.items.map((i) => i.productId) } }).toArray(); }));const lookup = time(() => ids.forEach((id) => db.ordersRef.aggregate([{ $match: { _id: id } }, { $unwind: "$items" }, { $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "p" } }]).toArray()));console.log("2 000 order-detail reads (each needs titles and prices):");console.log({ embedded_1_query: emb + " ms", referenced_2_queries: ref + " ms", referenced_1_lookup_aggregate: lookup + " ms" });2 000 order-detail reads (each needs titles and prices):{ embedded_1_query: '519 ms', referenced_2_queries: '929 ms', referenced_1_lookup_aggregate: '684 ms'}One query took 591 ms for the 2 000 reads; two queries per order 1 104 ms; a single $lookup aggregate per order 662 ms. There is no network in this test, so on a real network - where each round trip costs milliseconds - the gap grows with the number of round trips, not with the CPU.
What this module covers
Section titled “What this module covers”- Embedding versus referencing - read locality, duplication that drifts, atomic writes, and the real cost of
$lookup. - Modeling relationships - one-to-one, one-to-many, many-to-many, and the 16 MiB cliff measured.
- Schema design patterns - Subset, Computed, Bucket and Extended Reference, each run.
- Schema validation -
$jsonSchemarules with real rejections. - Anti-patterns - the mistakes that look fine at first and hurt at scale, each measured.
Tips and gotchas
Section titled “Tips and gotchas”- MongoDB is schema-flexible, not schema-less: documents can differ, but a deliberate, consistent shape keeps a collection fast and maintainable.
- “Model for your queries” means knowing your main access patterns before you design; a schema perfect for one read may be wrong for another.
- Duplication is allowed and sometimes desirable: the cost of a copy is keeping it in sync, the benefit is a faster, simpler read.
- A per-item loop over a referenced collection is the classic N+1: 4 queries for a 3-item order.
Reading AI-generated code
Section titled “Reading AI-generated code”Asked to “render an order with product titles”, an assistant normalizes like SQL and resolves each item on its own:
🐛 Spot the bug
// AI comment: "products live in their own collection - single source of truth"const order = await db.collection("ordersRef").findOne({ _id });for (const item of order.items) {item.product = await db.collection("products").findOne({ _id: item.productId });}return order;Show the answer
The design is fine; the loop is the bug. It is the N+1 pattern: one query for the order plus one per item. The profiler in the Node.js tab counted 4 queries for a 3-item order, against 1 for the embedded shape and 2 for a single $in lookup of all product ids - and the difference multiplies by every page view. Batch the ids into one find({ _id: { $in: [...] } }) (the two-query version shown in the Python, Go and Rust tabs), use a $lookup aggregate, or embed the fields the page always shows.
Mental Model
Section titled “Mental Model”Every relationship is a bet on which side of the read you want to pay for. Embedding pays at write time and in bytes (303 vs 216 per order, copies to keep in sync) to make the common read one query. Referencing pays at read time (2 queries, or a $lookup) to keep each fact in one place. Decide per relationship, from the queries.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Embedding | One query (591 ms for 2 000 reads); atomic writes | Bigger documents (303 vs 216 bytes); copies can drift; growth risk |
| Referencing | Small, stable documents; one copy of each fact | 2 queries or a $lookup per read (1 104 / 662 ms) |
| Bucket pattern | Fewer documents for high-volume series | Bucket size chosen in advance; grouping logic |
Common Misconceptions
Section titled “Common Misconceptions”- “MongoDB has no schema.” - every document has a shape; the database only enforces it if you add validation.
- “There is one correct schema per entity.” - the same order was 303 or 216 bytes depending on the access pattern.
- “A schema cannot change later.” - it can; you write the migration and plan the rollout (see schema validation).
- “Referencing is the clean way, embedding is a shortcut.” - the referenced order needed 2-4 queries for the same page.
Real-world example
Bosch - uses MongoDB (via Bosch IoT Insights) to store and analyze IoT data in real time, such as torque data from aircraft assembly tools and data from vehicle ECUs. It currently manages time-sequenced data manually and plans to adopt MongoDB’s Time Series Collections in the future for automatic grouping, rather than a hand-rolled bucket pattern as sometimes assumed (mongodb.com/customers/bosch).
Python/Go/Rust tabs mirror the verified shell/Node behaviour; each was run once in a scratch program against the same server (the referenced order plus its $in product lookup returned the same items) but they are not executed by the course’s automated checker.