Document vs relational
If you already know a relational database, the quickest way to get fluent in MongoDB is to translate the words you know into the words MongoDB uses. Most of the concepts line up directly; a couple of them differ in a way that is the whole point of the document model. This lesson is that translation table, plus a first look at the question that the Data Modeling module answers in full: when to embed related data and when to keep it in separate collections.
The vocabulary, mapped
Section titled “The vocabulary, mapped”Here is the relational world on the left and its MongoDB counterpart on the right:
- Database → Database. This one is the same word and the same idea: a named container for collections.
- Table → Collection. A collection holds documents the way a table holds rows, but it does not pin every document to identical columns.
- Row → Document. A document is one record. Unlike a row, it can nest arrays and sub-objects instead of being a flat list of cells.
- Column → Field. A field is a key-value pair inside a document. Fields are per-document, so two documents in a collection need not have the same set of fields.
- JOIN → Embedding or
$lookup. Relationships are handled either by embedding related data inside one document, or by linking documents and combining them at read time with the$lookupaggregation stage. - Schema → Flexible schema (with optional validation). The shape of your data is flexible by default, and you can layer on validation rules when you want the database to enforce a structure.
- Primary key →
_id. Every document has a unique_id, the same role a primary key plays for a row.
flowchart LR
subgraph SQL["Relational"]
direction TB
R1["Database"]
R2["Table"]
R3["Row"]
R4["Column"]
R5["JOIN"]
end
subgraph MDB["MongoDB"]
direction TB
M1["Database"]
M2["Collection"]
M3["Document"]
M4["Field"]
M5["Embedding or lookup"]
end
R1 --> M1
R2 --> M2
R3 --> M3
R4 --> M4
R5 --> M5 The big difference: relationships
Section titled “The big difference: relationships”In a relational database you almost always normalise: a member lives in a members table and their loans in a loans table, joined by a key. You assemble the full picture with a JOIN every time you query.
MongoDB gives you a choice. You can embed the related data directly inside the parent document, so one read returns everything. Or you can keep the data in separate collections and link by id, then combine them when you query using $lookup - MongoDB’s join-at-read-time. This fence builds both shapes for the same member and reads them back (output from mongo 8.3.11, mongosh 2.11.1, this Mac):
const ada = ObjectId("65f0b3d4e4b0a1c2e4b0a1d1");
// Shape 1: embedded - one document holds the loansdb.embedded.insertOne({ _id: ada, name: "Ada", borrowed: [ { title: "Compilers", due: "2026-07-01" }, { title: "Algorithms", due: "2026-07-15" },] });
// Shape 2: referenced - two collections linked by member_iddb.members.insertOne({ _id: ada, name: "Ada" });db.loans.insertMany([ { member_id: ada, title: "Compilers", due: "2026-07-01" }, { member_id: ada, title: "Algorithms", due: "2026-07-15" },]);
console.log(db.embedded.findOne({ name: "Ada" })); // one readconst joined = db.members.aggregate([ { $lookup: { from: "loans", localField: "_id", foreignField: "member_id", as: "borrowed" } }, { $project: { "borrowed._id": 0, "borrowed.member_id": 0 } },]).toArray(); // one aggregation, two collectionsconsole.log(joined);console.log("embedded doc bytes:", bsonsize(db.embedded.findOne()));assert.strictEqual(joined[0].borrowed.length, 2);{ _id: ObjectId('65f0b3d4e4b0a1c2e4b0a1d1'), name: 'Ada', borrowed: [ { title: 'Compilers', due: '2026-07-01' }, { title: 'Algorithms', due: '2026-07-15' } ]}[ { _id: ObjectId('65f0b3d4e4b0a1c2e4b0a1d1'), name: 'Ada', borrowed: [ { title: 'Compilers', due: '2026-07-01' }, { title: 'Algorithms', due: '2026-07-15' } ] }]embedded doc bytes: 150The two reads return the same picture - the difference is where the work happens: the embedded shape did one findOne, the referenced shape ran a $lookup across two collections.
There is no foreign key
Section titled “There is no foreign key”The relational reflex is “the database will stop me from orphaning a loan”. MongoDB will not:
db.members.drop(); db.loans.drop(); // start clean: this fence reuses the names aboveconst ada = ObjectId("65f0b3d4e4b0a1c2e4b0a1d1");db.members.insertOne({ _id: ada, name: "Ada" });db.loans.insertMany([{ member_id: ada, title: "Compilers" }, { member_id: ada, title: "Algorithms" }]);
// 1. insert a loan for a member that never existed - acceptedconsole.log(db.loans.insertOne({ member_id: ObjectId("000000000000000000000001"), title: "Orphan" }).acknowledged);// 2. delete the member while loans still point at them - accepteddb.members.deleteOne({ _id: ada });// 3. $lookup does not error - it returns an empty arrayconsole.log(db.loans.aggregate([ { $lookup: { from: "members", localField: "member_id", foreignField: "_id", as: "member" } }, { $project: { _id: 0, title: 1, member: 1 } },]).toArray());console.log("loans still pointing at Ada:", db.loans.countDocuments({ member_id: ada }));assert.strictEqual(db.loans.countDocuments({ member_id: ada }), 2);true[ { title: 'Compilers', member: [] }, { title: 'Algorithms', member: [] }, { title: 'Orphan', member: [] }]loans still pointing at Ada: 2All three “violations” succeeded silently. Integrity between collections is your job (in application code, or by embedding so the child cannot outlive the parent).
When to embed vs keep separate
Section titled “When to embed vs keep separate”The full treatment is the Data Modeling module, but the instinct to build now is this:
- Embed when the related data belongs to one parent, is read together with it, and does not grow without bound. A member’s borrowed books, an order’s line items, a post’s recent comments — these read as one document in one trip.
- Keep separate when the data is shared between many parents, referenced independently, or grows large. A catalogue of books that many members borrow should be its own collection, referenced by id, not copied into every member.
The guiding question is how do I read this data? If you almost always want the parent and the children together, embedding earns its keep. If the children have a life of their own, link them.
Tips and gotchas
Section titled “Tips and gotchas”- Do not reflexively normalise. Coming from SQL, the urge is to split everything into separate collections. In MongoDB, data that is read together usually belongs together, in one document.
- A collection is not a table. It does not enforce a fixed set of columns. Two documents can carry different fields, and that is allowed by design.
$lookupexists, but it is not a free join. Embedding is generally faster for data you read together; reach for$lookupwhen the data genuinely belongs in separate collections._idis your primary key for free. You get a unique, indexed identifier on every document without declaring one.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant porting a SQL schema often writes the delete the way it would look with ON DELETE CASCADE:
🐛 Spot the bug
// AI comment: "loans reference members; deleting the member removes their loans (like ON DELETE CASCADE)"await db.collection("members").deleteOne({ _id: memberId });// loans are cleaned up automatically by the foreign keyShow the answer
MongoDB has no foreign keys and no cascade. We ran exactly this: the member was deleted, the two loans stayed (loans still pointing at Ada: 2 above), and every $lookup from loans to members now returns member: []. The fix is to make the cleanup explicit - delete the children in the same unit of work (or embed them so they go with the parent):
db.members.drop(); db.loans.drop();const ada = ObjectId("65f0b3d4e4b0a1c2e4b0a1d1");db.members.insertOne({ _id: ada, name: "Ada" });db.loans.insertMany([{ member_id: ada, title: "Compilers" }, { member_id: ada, title: "Algorithms" }]);const loansGone = db.loans.deleteMany({ member_id: ada }).deletedCount; // children firstconst memberGone = db.members.deleteOne({ _id: ada }).deletedCount;console.log({ loansGone, memberGone, orphans: db.loans.countDocuments({ member_id: ada }) });assert.strictEqual(db.loans.countDocuments({ member_id: ada }), 0);{ loansGone: 2, memberGone: 1, orphans: 0 }Two separate deletes are not atomic - a crash between them still leaves orphans; the transactions module shows how to make them one unit.
Mental Model
Section titled “Mental Model”A relational database enforces relationships between tables; MongoDB stores relationships inside or alongside documents and leaves enforcement to you. Embedding makes the relationship physical (the child bytes are inside the parent’s, 150 bytes for Ada and her two loans), referencing makes it logical (a member_id value that nothing checks). Choose by how you read: together -> embed; independently -> reference.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Denormalized document model (embed) | Reading one document gets you everything, no join needed - fast for workloads that always read parent and child together | Data duplicated across documents must be updated in multiple places when it changes, and a document can grow toward its 16 MiB limit if an array is unbounded |
Referenced model + $lookup | One copy of each fact; children can be read on their own | No referential integrity (orphans proven above); a join at read time on every query that needs both |
| Normalized relational model (foreign key) | No duplicated data, one place to fix, and foreign keys give you referential integrity from the database | Nearly every query needs to join multiple tables, which costs more as the number of related tables grows |
Common Misconceptions
Section titled “Common Misconceptions”- “
$lookupis a SQL JOIN with the same guarantees.” - no foreign-key integrity: the real run above inserted an orphan loan, deleted a referenced member, and$lookupreturnedmember: []with no error. - “Embedding is always faster, so embed everything.” - an unbounded child array (an ever-growing activity log) will eventually hit the 16 MiB document limit and make the parent slow to read and write. Keep it separate from the start.
- “Sorting by
_idis the same as sorting by exact creation time.” - within one process, ids increase in creation order (documents-and-bson lesson). Across processes the 3-byte counter starts at a random value, so two ids created in the same second by different processes sort in either order: in 40 pairs of ids from two separate Node processes, 37 pairs shared a timestamp second and in 20 of those 37 the later id sorted first. Keep your own timestamp field when order matters. - “A collection is a table.” - it enforces no fixed set of columns; two documents can carry different fields.
- “Coming from SQL, normalise reflexively.” - data read together usually belongs together in one document.
Real-world example
The MongoDB manual’s own data-modeling guidance frames the same decision: embed for “contains” and “one-to-few” relationships, reference when data is shared or unbounded (Data modeling introduction, consulted 2026-09-30); the Data Modeling module of this course goes through each case with measurements.
Python/Go/Rust are not used in this lesson; the fences ran in mongosh.