Skip to content

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.

mongosh/rel-seed.js
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: 3

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"]
One-to-one: embed the related data as a sub-object in the same document
mongosh/rel-one-to-one.js
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',
profile: {
displayName: 'User 42',
bio: 'Writes algorithms.',
joinedYear: 2020
}
}
one read returns the profile: User 42

Split 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.

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
One-to-many: embed an array when children are few and bounded; reference from the child when the many side grows large
mongosh/rel-one-to-many.js
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=20000
posts of user_42, with the index : FETCH > IXSCAN(authorId_1_at_-1) returned=20 keys=20 docs=20
page 1 (10 newest) : LIMIT > FETCH > IXSCAN(authorId_1_at_-1) returned=10 keys=10 docs=10

The dividing question is “could this list grow without limit?” A post’s tags will not; a user’s posts might.

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
Many-to-many: store an array of referenced ids on the side you read from most
mongosh/rel-many-to-many.js
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);
without a transaction: crash between the two writes
student side: [ 'c_0' ] | course side: [] <- inconsistent
with 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.

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:

mongosh/rel-16mb.js
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 16777216

Before 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:

mongosh/rel-16mb-fix.js
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 hold
const 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 ms
last 20 messages: LIMIT > FETCH > IXSCAN(roomId_1_n_-1) returned=20 keys=20 docs=20
messages 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.

  • 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).

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.

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.

PatternBenefitCost
One-to-few embeddingOne read; atomic writeOnly safe when the number of children is truly bounded
One-to-many, reference from the childParent stays tiny; 10-key page reads with an indexA second query; needs an index on the reference
Many-to-many, arrays on both sidesFast reads both ways, no $lookupTwo writes per change; needs a transaction to stay consistent
Unbounded embedded arraySimple at first5x slower updates at 15 MB, then a hard failure
  • “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.

A user has one profile that is always read with the user. Which model fits?
One-to-many where the many side can grow without limit. Where should the reference live?
What happens to a chat room that $push-es every message into one document?
Enrolling a student in a two-sided many-to-many needs two writes. What keeps them consistent?
Why keep the reference array on only the side you read from?