Skip to content

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.

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 $lookup aggregation 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
Relational concepts and their MongoDB counterparts, side by side

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

mongosh/dvr-embed-vs-lookup.js
const ada = ObjectId("65f0b3d4e4b0a1c2e4b0a1d1");
// Shape 1: embedded - one document holds the loans
db.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_id
db.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 read
const 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 collections
console.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: 150

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

The relational reflex is “the database will stop me from orphaning a loan”. MongoDB will not:

mongosh/dvr-no-foreign-key.js
db.members.drop(); db.loans.drop(); // start clean: this fence reuses the names above
const 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 - accepted
console.log(db.loans.insertOne({ member_id: ObjectId("000000000000000000000001"), title: "Orphan" }).acknowledged);
// 2. delete the member while loans still point at them - accepted
db.members.deleteOne({ _id: ada });
// 3. $lookup does not error - it returns an empty array
console.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: 2

All three “violations” succeeded silently. Integrity between collections is your job (in application code, or by embedding so the child cannot outlive the parent).

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.

  • 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.
  • $lookup exists, but it is not a free join. Embedding is generally faster for data you read together; reach for $lookup when the data genuinely belongs in separate collections.
  • _id is your primary key for free. You get a unique, indexed identifier on every document without declaring one.

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 key
Show 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):

mongosh/dvr-cascade-fix.js
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 first
const 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.

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.

ChoiceBenefitCost
Denormalized document model (embed)Reading one document gets you everything, no join needed - fast for workloads that always read parent and child togetherData 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 + $lookupOne copy of each fact; children can be read on their ownNo 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 databaseNearly every query needs to join multiple tables, which costs more as the number of related tables grows
  • “$lookup is a SQL JOIN with the same guarantees.” - no foreign-key integrity: the real run above inserted an orphan loan, deleted a referenced member, and $lookup returned member: [] 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 _id is 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.

What is the MongoDB counterpart of a relational table?
In MongoDB, how are relationships between records handled?
When is embedding usually the better choice over separate collections?
You deleteOne a member while two loan documents still hold that member_id. Then you $lookup from loans to members. What comes back?
Two different processes each create an ObjectId in the same second. Which statement is true?