Embedding versus referencing
There are two ways to express a relationship between documents, and almost every modeling decision is a choice between them. You can embed: place the related data inside the parent document, nested as a sub-object or an array. Or you can reference: keep the related data in its own collection and store an identifier - usually the _id - in the parent, then look it up when you need it. Embedding favors reading; referencing favors a single source of truth.
The lesson uses the module’s order data: 500 products, and 10 000 orders in two versions (ordersEmb with a copy of title and price on each item, ordersRef with only productId), plus a revenue() helper that totals qty x price. 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");
globalThis.revenue = (coll) => coll.aggregate([{ $unwind: "$items" }, { $group: { _id: null, total: { $sum: { $multiply: ["$items.qty", "$items.price"] } } } }]).toArray()[0].total;products: 500 | orders: 10000 embedded / 10000 referencedEmbedding: keep it together
Section titled “Embedding: keep it together”An embedded order carries its items, so the document is the complete answer to “show me this order” (see the ordersEmb sample in the previous lesson). The win is read locality: one read, no join. Embedded data also changes atomically with its parent: the manual states that “write operations are atomic on the single-document level, even if modifying multiple values” (atomicity, consulted 2026-09-30). One updateOne changing an item quantity and the status:
db.scratch.insertOne(db.ordersEmb.findOne({ _id: "order_1" })); // work on a copy so later totals stay untouchedconst before = db.scratch.findOne({ _id: "order_1" });const r = db.scratch.updateOne({ _id: "order_1" }, { $set: { status: "returned" }, $inc: { "items.0.qty": 1 } }); // two changes, one document: one atomic writeconst after = db.scratch.findOne({ _id: "order_1" });console.log({ matched: r.matchedCount, modified: r.modifiedCount }, "| items.0.qty", before.items[0].qty, "->", after.items[0].qty, "| status", before.status, "->", after.status);assert.strictEqual(r.modifiedCount, 1);{ matched: 1, modified: 1 } | items.0.qty 3 -> 4 | status shipped -> returnedThe costs are duplication (titles and prices are copies) and document growth (every embedded item makes the order bigger, and an array that grows forever meets the 16 MiB ceiling - see the next lesson).
Referencing: keep it separate
Section titled “Referencing: keep it separate”When you reference, the parent stores only an identifier and the real data lives once, in its own collection. There is no duplication and the order stays small; the cost is the mirror image of embedding - resolving the ids needs a second lookup, either in application code or with $lookup. The same total-revenue question, answered from each shape (the Node.js tab resolves one order’s products with $lookup):
const time = (f) => { const t = Date.now(); const r = f(); return [r, Date.now() - t]; };const [e, eMs] = time(() => revenue(db.ordersEmb));const [r, rMs] = time(() => db.ordersRef.aggregate([{ $unwind: "$items" }, { $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "p" } }, { $unwind: "$p" }, { $group: { _id: null, total: { $sum: { $multiply: ["$items.qty", "$p.price"] } } } }]).toArray()[0].total);console.log({ embeddedRevenue: e, embeddedMs: eMs, referencedRevenue: r, referencedLookupMs: rMs });assert.strictEqual(e, r); // same revenue both ways (before any price change)const x = db.ordersRef.explain().aggregate([{ $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "p" } }]);console.log("how the server joins them:", JSON.stringify(x).match(/"strategy":"\w+"/)[0], "- the old lesson said $lookup had no strategy choice");const products = db.collection("products"), orders = 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 }]);await orders.insertOne({ _id: "o1", items: [{ productId: "BK-1", qty: 1 }, { productId: "BK-2", qty: 2 }] });const [o] = await orders.aggregate([ { $match: { _id: "o1" } }, { $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "products" } },]).toArray();console.log(o.products.map((p) => p.title));if (o.products.length !== 2) throw new Error("lookup should resolve both products");rows = list(db.ordersRef.aggregate([ {"$match": {"_id": "o1"}}, {"$lookup": { "from": "products", "localField": "items.productId", "foreignField": "_id", "as": "products", }},]))pipeline := mongo.Pipeline{ {{Key: "$match", Value: bson.D{{Key: "_id", Value: "o1"}}}}, {{Key: "$lookup", Value: bson.D{ {Key: "from", Value: "products"}, {Key: "localField", Value: "items.productId"}, {Key: "foreignField", Value: "_id"}, {Key: "as", Value: "products"}, }}},}cur, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}var rows []bson.Mif err := cur.All(ctx, &rows); err != nil { return err}let mut cursor = coll .aggregate(vec![ doc! { "$match": { "_id": "o1" } }, doc! { "$lookup": { "from": "products", "localField": "items.productId", "foreignField": "_id", "as": "products", } }, ]) .await?;{ embeddedRevenue: 2040193, embeddedMs: 26, referencedRevenue: 2040193, referencedLookupMs: 324}how the server joins them: "strategy":"IndexedLoopJoin" - the old lesson said $lookup had no strategy choice[ 'Distributed Systems', 'The Mythical Man-Month' ]Both shapes agree on the total (the fence asserts it) but the embedded scan took 26 ms and the $lookup version 324 ms - about 12x. The old lesson claimed $lookup has “no optimizer choosing a join strategy”; explain reports IndexedLoopJoin because products._id is indexed (the lookup lesson shows HashJoin and NestedLoopJoin too). Referencing did not lose because of a missing optimizer; it paid for an actual join per item.
Copies drift - and sometimes should
Section titled “Copies drift - and sometimes should”Now the catalogue price of BK-7 changes from 17 to 99:
const line = (c, p) => db[c].aggregate([{ $match: { "items.sku": p } }, { $count: "orders" }]).toArray()[0]?.orders;console.log("orders containing BK-7 (embedded):", db.ordersEmb.countDocuments({ "items.sku": "BK-7" }), "| (referenced):", db.ordersRef.countDocuments({ "items.productId": "BK-7" }));console.log("catalogue price before:", db.products.findOne({ _id: "BK-7" }).price);db.products.updateOne({ _id: "BK-7" }, { $set: { price: 99 } }); // the catalogue price changesconsole.log("catalogue price after :", db.products.findOne({ _id: "BK-7" }).price);console.log("an embedded order still shows :", db.ordersEmb.findOne({ "items.sku": "BK-7" }).items.find((i) => i.sku === "BK-7").price, "(its copy did not change)");console.log("a referenced order resolves to :", db.products.findOne({ _id: "BK-7" }).price, "(one place to update)");orders containing BK-7 (embedded): 69 | (referenced): 69catalogue price before: 17catalogue price after : 99an embedded order still shows : 17 (its copy did not change)a referenced order resolves to : 99 (one place to update)The referenced shape follows the catalogue instantly - one document changed. The embedded copy kept 17. Whether that is a bug depends on the data: a product’s current price wants a reference, but the price paid on an order is a historical fact, and the embedded copy is exactly the snapshot you want. Rewriting it is the bug:
The two side by side
Section titled “The two side by side”flowchart TB
subgraph Embed["Embedding - one document"]
E0["Read the order"] --> E1["Order document"]
E1 --> E2["items array nested inside"]
E2 --> E3["Done - one read, no join"]
end
subgraph Reference["Referencing - linked documents"]
F0["Read the order"] --> F1["Order document with productId values"]
F1 --> F2["Second lookup into products"]
F2 --> F3["Join results to render"]
end A useful rule of thumb: embed data that is read together, written together and bounded in size; reference data that is large, shared across many parents, frequently updated on its own, or unbounded in count.
In Compass: open the Schema tab on a collection: Compass samples your documents to show which fields exist, their types, and how often arrays and nested objects appear - a quick way to see whether you have been embedding or referencing in practice.
Tips and gotchas
Section titled “Tips and gotchas”- Embedding gives atomic writes for free: parent and embedded children change in one operation.
- Referencing keeps shared data in one place - an update is seen everywhere - but each read that needs it pays for the lookup (324 ms vs 26 ms above).
- Mix both: embed a small snapshot you always display and keep a reference to the full record.
- Ask of every embedded copy: is it a snapshot (keep it) or a mirror (keep it in sync)?
- An array that only ever grows is the commonest way an embedding decision turns into a problem.
Reading AI-generated code
Section titled “Reading AI-generated code”After a catalogue price change an assistant is asked to “make the orders consistent” and reaches for updateMany with arrayFilters:
🐛 Spot the bug
// AI comment: "propagate the new catalogue price so embedded prices are not stale"await db.collection("ordersEmb").updateMany({ "items.sku": "BK-7" },{ $set: { "items.$[i].price": 99 } },{ arrayFilters: [{ "i.sku": "BK-7" }] });Show the answer
It runs without error and is even a correct use of arrayFilters - which is why it is dangerous. It rewrote 69 past orders; total revenue moved from 2 040 193 to 2 051 509, and order_373 changed from 356 to 602 after the customer had already paid 356. The embedded price on an order is what was charged, not a mirror of the catalogue. Leave it alone, and resolve only display data (titles) by reference if it should follow the catalogue:
const total = () => +revenue(db.ordersEmb).toFixed(2);const revenueBefore = total();const order = db.ordersEmb.findOne({ "items.sku": "BK-7" });const oldTotal = order.items.reduce((n, i) => n + i.qty * i.price, 0);// AI: "keep embedded prices consistent with the catalogue" (the catalogue price of BK-7 is now 99)const r = db.ordersEmb.updateMany({ "items.sku": "BK-7" }, { $set: { "items.$[i].price": 99 } }, { arrayFilters: [{ "i.sku": "BK-7" }] });const newTotal = db.ordersEmb.findOne({ _id: order._id }).items.reduce((n, i) => n + i.qty * i.price, 0);console.log({ ordersRewritten: r.modifiedCount, revenueBefore, revenueAfter: total() });console.log("one past order:", order._id, "was worth", oldTotal, "and is now", newTotal);assert.notStrictEqual(revenueBefore, total());{ ordersRewritten: 69, revenueBefore: 2040193, revenueAfter: 2051509 }one past order: order_373 was worth 356 and is now 602Mental Model
Section titled “Mental Model”Ask what each field means. If it means “the current value of something that lives elsewhere”, reference it. If it means “the value at the moment this document was created”, embed it - it is a fact about this document, and its copy is the point. Then check bounds: embed only what cannot grow without limit.
Trade-offs
Section titled “Trade-offs”| Scenario | Benefit of the recommended choice | Cost |
|---|---|---|
| 1:1 (user + profile) - embed | One read, atomic write | Larger document; harder to split later |
| 1:many bounded (post + tags) - embed | Fast, simple, no join | Must be sure the bound is real |
| 1:many unbounded (user + posts) - reference from the child | Parent stays small; children paginate independently | Second query; no atomic write across parent and child |
| Price paid on an order - embed a snapshot | History stays true; 26 ms scans | Never follows the catalogue (by design) |
| many:many - id arrays on both sides | Fast reads both ways | Double the writes; keep both sides in sync |
Common Misconceptions
Section titled “Common Misconceptions”- “Embedding is always faster.” - only while the array is bounded; an unbounded array slows every read and write (see the next lesson, measured).
- “
$lookuphas no join strategy.” - explain reported IndexedLoopJoin. - “Stale embedded copies are always bugs.” - the price paid on an order is history; syncing it rewrote 69 orders.
- “One rule fits every relationship.” - decide each from its access pattern and growth.
Real-world example
The MongoDB manual’s data-modeling guidance frames the same decision: embed when “Your application queries pieces of information together”, and reference when “Your embedded data grows without bounds” or “Data duplication is too complicated to manage and not preferred” (Embedded Data Versus References, consulted 2026-09-30).
Python/Go/Rust tabs mirror the verified shell/Node behaviour; each was run once in a scratch program against the same server (the $lookup returned both products for the order) but they are not executed by the course’s automated checker.