Modeling relationships
Every relationship falls into one of three shapes by cardinality - how many of one thing relate to how many of another. A user has one profile (one-to-one). A user writes many posts (one-to-many). A student enrolls in many courses and a course has many students (many-to-many). In a relational database the shape dictates the mechanics: foreign keys and join tables. In MongoDB the shape only narrows your options; you still choose embed or reference for each, using the trade-offs from the previous lesson.
The lesson seeds its own data: 1 000 users with an embedded profile, 20 000 posts referencing their author, and small students / courses collections. Captured on mongo 8.3.11, mongosh 2.11.1 and Node driver 7.7.0 on one Mac (the transaction examples need the replica-set container from the setup lesson); Python, Go and Rust tabs were each run once in a scratch program.
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;};db.users.insertMany(Array.from({ length: 1000 }, (_, i) => ({ _id: "user_" + i, email: "u" + i + "@example.test", profile: { displayName: "User " + i, bio: "Writes algorithms.", joinedYear: 2020 + (i % 7) } })));db.posts.insertMany(Array.from({ length: 20000 }, (_, i) => ({ _id: "post_" + i, authorId: "user_" + (i % 1000), title: "Post " + i, tags: ["mongodb", "modeling", "tag" + (i % 5)], at: new Date(1700000000000 + i * 60000) })));db.students.insertMany(Array.from({ length: 5 }, (_, i) => ({ _id: "student_" + i, name: "S" + i, courseIds: [] })));db.courses.insertMany(Array.from({ length: 3 }, (_, i) => ({ _id: "c_" + i, title: "Course " + i, studentIds: [] })));console.log("users:", db.users.countDocuments(), "| posts:", db.posts.countDocuments(), "| students:", db.students.countDocuments(), "| courses:", db.courses.countDocuments());users: 1000 | posts: 20000 | students: 5 | courses: 3One-to-one
Section titled “One-to-one”When one document relates to exactly one other, embedding is usually the natural fit - the related data is bounded, owned by a single parent and read alongside it. A user and their profile become one document:
flowchart LR U["User document"] --> P["profile sub-object embedded inside"]
const u = db.users.findOne({ _id: "user_42" });console.log(u);console.log("one read returns the profile:", u.profile.displayName);assert.strictEqual(u.profile.displayName, "User 42");{ _id: 'user_42', email: '[email protected]', profile: { displayName: 'User 42', bio: 'Writes algorithms.', joinedYear: 2020 }}one read returns the profile: User 42Split a one-to-one only when the related part is large, rarely read, or accessed independently - for example a heavyweight binary or an audit block in its own collection so the hot document stays small.
One-to-many
Section titled “One-to-many”With one parent and many children there are two good shapes. If the children are few and bounded, embed them as an array: a post’s three tags are a clean embed. When the “many” side can grow or is queried on its own, reference from the child: each post stores authorId. That reference needs an index, or listing a user’s posts scans every post:
flowchart TB
subgraph FewBounded["Few and bounded - embed array"]
A0["Post document"] --> A1["tags: array of values"]
end
subgraph ManyUnbounded["Many or unbounded - reference from child"]
B0["User document"]
B1["Post: authorId points to user"]
B2["Post: authorId points to user"]
B1 --> B0
B2 --> B0
end console.log("embedded tags (bounded) :", db.posts.findOne({ _id: "post_7" }).tags);console.log("posts of user_42, no index on authorId :", plan(db.posts.find({ authorId: "user_42" })));db.posts.createIndex({ authorId: 1, at: -1 });console.log("posts of user_42, with the index :", plan(db.posts.find({ authorId: "user_42" }).sort({ at: -1 })));console.log("page 1 (10 newest) :", plan(db.posts.find({ authorId: "user_42" }).sort({ at: -1 }).limit(10)));embedded tags (bounded) : [ 'mongodb', 'modeling', 'tag2' ]posts of user_42, no index on authorId : COLLSCAN returned=20 keys=0 docs=20000posts of user_42, with the index : FETCH > IXSCAN(authorId_1_at_-1) returned=20 keys=20 docs=20page 1 (10 newest) : LIMIT > FETCH > IXSCAN(authorId_1_at_-1) returned=10 keys=10 docs=10The dividing question is “could this list grow without limit?” A post’s tags will not; a user’s posts might.
Many-to-many
Section titled “Many-to-many”Each side relates to many of the other - students and courses. The clean approach is referencing with id arrays, on one side or both. An array on the side you read from is one write per enrolment. Arrays on both sides make both directions fast to read but every enrolment is two writes that must succeed together - otherwise a crash between them leaves the data inconsistent, as the first half of this run shows; a multi-document transaction (second half, and the Node.js tab) fixes it:
flowchart TB S1["Student: courseIds array"] S2["Student: courseIds array"] C1["Course c_db"] C2["Course c_os"] S1 --> C1 S1 --> C2 S2 --> C1
const enroll = (sid, cid, failBetween, session) => { db.students.updateOne({ _id: sid }, { $addToSet: { courseIds: cid } }, { session }); if (failBetween) throw new Error("crash between the two writes"); db.courses.updateOne({ _id: cid }, { $addToSet: { studentIds: sid } }, { session });};try { enroll("student_0", "c_0", true); } catch (e) { console.log("without a transaction:", e.message); }console.log(" student side:", db.students.findOne({ _id: "student_0" }).courseIds, "| course side:", db.courses.findOne({ _id: "c_0" }).studentIds, "<- inconsistent");const session = db.getMongo().startSession();const sdb = session.getDatabase(db.getName());try { session.withTransaction(() => { sdb.students.updateOne({ _id: "student_1" }, { $addToSet: { courseIds: "c_1" } }); throw new Error("crash between the two writes"); }); }catch (e) { console.log("with a transaction :", e.message); }session.endSession();console.log(" student side:", db.students.findOne({ _id: "student_1" }).courseIds, "| course side:", db.courses.findOne({ _id: "c_1" }).studentIds, "<- rolled back, consistent");assert.strictEqual(db.students.findOne({ _id: "student_1" }).courseIds.length, 0);const students = db.collection("students"), courses = db.collection("courses");await students.insertOne({ _id: "student_7", name: "Linus", courseIds: [] });await courses.insertOne({ _id: "c_db", title: "Database Systems", studentIds: [] });const session = client.startSession();try { await session.withTransaction(async () => { // both sides or neither await students.updateOne({ _id: "student_7" }, { $addToSet: { courseIds: "c_db" } }, { session }); await courses.updateOne({ _id: "c_db" }, { $addToSet: { studentIds: "student_7" } }, { session }); });} finally { await session.endSession(); }console.log(await students.findOne({ _id: "student_7" }), await courses.findOne({ _id: "c_db" }));const s = await students.findOne({ _id: "student_7" }), c = await courses.findOne({ _id: "c_db" });if (!s.courseIds.includes("c_db") || !c.studentIds.includes("student_7")) throw new Error("both sides should be updated");with client.start_session() as s: def enroll(s): db.students.update_one({"_id": "student_7"}, {"$addToSet": {"courseIds": "c_db"}}, session=s) db.courses.update_one({"_id": "c_db"}, {"$addToSet": {"studentIds": "student_7"}}, session=s) s.with_transaction(enroll)session, err := client.StartSession()if err != nil { return err}defer session.EndSession(ctx)_, err = session.WithTransaction(ctx, func(sc context.Context) (any, error) { if _, err := db.Collection("students").UpdateOne(sc, bson.M{"_id": "student_7"}, bson.M{"$addToSet": bson.M{"courseIds": "c_db"}}); err != nil { return nil, err } return db.Collection("courses").UpdateOne(sc, bson.M{"_id": "c_db"}, bson.M{"$addToSet": bson.M{"studentIds": "student_7"}})})if err != nil { return err}let mut session = client.start_session().await?;session.start_transaction().await?;db.collection::<Document>("students") .update_one(doc! { "_id": "student_7" }, doc! { "$addToSet": { "courseIds": "c_db" } }) .session(&mut session) .await?;db.collection::<Document>("courses") .update_one(doc! { "_id": "c_db" }, doc! { "$addToSet": { "studentIds": "student_7" } }) .session(&mut session) .await?;session.commit_transaction().await?;without a transaction: crash between the two writes student side: [ 'c_0' ] | course side: [] <- inconsistentwith a transaction : crash between the two writes student side: [] | course side: [] <- rolled back, consistent{ _id: 'student_7', name: 'Linus', courseIds: [ 'c_db' ] } { _id: 'c_db', title: 'Database Systems', studentIds: [ 'student_7' ] }Transactions require a replica set (the standalone container from the setup lesson rejects them) and add coordination cost; choose two-sided references only when you truly read both directions often.
The 16 MiB limit and unbounded arrays
Section titled “The 16 MiB limit and unbounded arrays”Every document has a hard ceiling of 16 mebibytes (limits, consulted 2026-09-30). That is generous, but it turns embedding into a trap when the array has no natural bound. Here a chat room $pushes 8 KB messages into one document. At each checkpoint we time a tiny $set of one unrelated field:
db.rooms.insertOne({ _id: "room1", messages: [] });const pad = "x".repeat(8000), size = () => db.rooms.aggregate([{ $match: { _id: "room1" } }, { $project: { b: { $bsonSize: "$$ROOT" } } }]).toArray()[0].b;const marks = [1, 100, 500, 1000, 1500, 2000], rows = [];let n = 0, err = null;for (;;) { try { db.rooms.updateOne({ _id: "room1" }, { $push: { messages: { n, text: pad } } }); } catch (e) { err = "code " + e.code + ": " + e.message.slice(e.message.indexOf("Resulting")); break; } n++; if (marks.includes(n)) { // time a tiny update ($set one field) on the document as it is now const t = Date.now(); for (let i = 0; i < 5; i++) db.rooms.updateOne({ _id: "room1" }, { $set: { lastActive: i } }); rows.push({ pushes: n, docMB: +(size() / 1048576).toFixed(2), msPerTinyUpdate: (Date.now() - t) / 5 }); }}console.log(rows);console.log("first failure after", n, "pushes ->", err);assert.ok(err.includes("16777216"));[ { pushes: 1, docMB: 0.01, msPerTinyUpdate: 1.4 }, { pushes: 100, docMB: 0.77, msPerTinyUpdate: 1 }, { pushes: 500, docMB: 3.83, msPerTinyUpdate: 3.2 }, { pushes: 1000, docMB: 7.66, msPerTinyUpdate: 5.8 }, { pushes: 1500, docMB: 11.48, msPerTinyUpdate: 6.4 }, { pushes: 2000, docMB: 15.31, msPerTinyUpdate: 7.6 }]first failure after 2089 pushes -> code 10334: Resulting document after update is larger than 16777216Before any error, the same tiny update became about about five times slower (1.4 ms at 0.01 MB to 7.6 ms at 15.3 MB) because the server rewrites a larger and larger document on every change - the document does not literally “move” (that was the old MMAPv1 storage engine); WiredTiger writes a new version of it. Push number 2 090 hit the wall with Resulting document after update is larger than 16777216. The fix is to reference the messages from the child, as in the one-to-many section:
db.roomMessages.createIndex({ roomId: 1, n: -1 });const pad = "x".repeat(8000);db.roomMessages.insertMany(Array.from({ length: 2100 }, (_, n) => ({ roomId: "room2", n, text: pad }))); // more than the array version could holdconst t = Date.now(); for (let i = 0; i < 5; i++) db.rooms.updateOne({ _id: "room2" }, { $set: { lastActive: i } }, { upsert: true });console.log("room document stays tiny; tiny update:", (Date.now() - t) / 5, "ms");console.log("last 20 messages:", plan(db.roomMessages.find({ roomId: "room2" }).sort({ n: -1 }).limit(20)));console.log("messages stored:", db.roomMessages.countDocuments({ roomId: "room2" }), "(no per-document ceiling)");room document stays tiny; tiny update: 1.4 mslast 20 messages: LIMIT > FETCH > IXSCAN(roomId_1_n_-1) returned=20 keys=20 docs=20messages stored: 2100 (no per-document ceiling)The room document stays tiny, updating it costs the same as on day one, and the newest 20 messages are an index read of 20 keys. Long before 16 MiB, growth is the enemy, not just the ceiling; the Bucket pattern (next lesson) is the middle path when you want several items per document but a cap on each.
Tips and gotchas
Section titled “Tips and gotchas”- Cardinality narrows your options; access and growth decide.
- One-to-one almost always embeds; a large or rarely-read sub-part may earn its own collection.
- For one-to-many ask “can the many side grow without limit?” If yes, reference from the child and index the reference.
- Two-sided many-to-many references cost two writes per change; use a transaction and only when both directions are hot.
- Performance degrades long before 16 MiB (about 5x slower small updates at 15 MB here).
Reading AI-generated code
Section titled “Reading AI-generated code”Asked to “store the chat history for a room”, an assistant keeps everything in the room document:
🐛 Spot the bug
// AI comment: "one document per room - reads are a single lookup"await db.collection("rooms").updateOne({ _id: roomId },{ $push: { messages: { n, text } } });Show the answer
It works for hundreds of messages, which is exactly how it passes review. The run above kept going until push 2 090 and then failed for good, and every earlier update got slower as the array grew. There is no way to trim it from the failing write, so the room is stuck until you migrate. Store one document per message with an indexed roomId (the fix block above stores 2 100 messages, more than the array could ever hold) and keep only counters and metadata on the room.
Mental Model
Section titled “Mental Model”Ask of each “many” side: does it have a natural maximum I can name? Tags per post: yes, embed. Messages per room: no, reference. Enrolments: unbounded on both sides, so reference and decide which direction you read. A bound you cannot name is a bound you do not have.
Trade-offs
Section titled “Trade-offs”| Pattern | Benefit | Cost |
|---|---|---|
| One-to-few embedding | One read; atomic write | Only safe when the number of children is truly bounded |
| One-to-many, reference from the child | Parent stays tiny; 10-key page reads with an index | A second query; needs an index on the reference |
| Many-to-many, arrays on both sides | Fast reads both ways, no $lookup | Two writes per change; needs a transaction to stay consistent |
| Unbounded embedded array | Simple at first | 5x slower updates at 15 MB, then a hard failure |
Common Misconceptions
Section titled “Common Misconceptions”- “Cardinality decides embed vs reference.” - it only narrows; access and growth decide.
- “A list that is small today is bounded.” - ask whether it can grow, not how big it is now.
- “The server moves the whole document on each update.” - that was MMAPv1; WiredTiger rewrites it, and the cost still grows with size.
- “Reference arrays on both sides are free.” - a crash between the two writes left them out of sync.
- “16 MiB is the only problem.” - updates were already 5x slower at 15 MB.
Real-world example
The MongoDB manual lists exactly the cases used here: reference when “Your embedded data grows without bounds” and note the hard 16 mebibyte BSON document size limit (Embedded Data Versus References; limits, 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 replica-set server (the two-sided enrolment committed on both sides) but they are not executed by the course’s automated checker.