Embed vs reference in MongoDB: a decision framework
Seven ordered questions that decide embed, reference or a hybrid, the mechanical cost of each choice, and a help desk schema worked through end to end.
Embed when the related data is bounded, is read with its parent, and is not needed on its own. Reference when any of those three fails. Most real relationships fail one test and pass another, which is why the answer is so often a hybrid: a reference for the full data, plus a small embedded copy of what the frequent read needs. The seven questions below, asked in order, get you to one of those outcomes, and the sections after them explain what each choice costs mechanically so you can judge the close calls.
The four possible outcomes
Outcome
Shape
Typical use
Embed
Child lives inside the parent as a subdocument or array
Addresses in a customer, line items in an order
Reference
Child in its own collection, holding the parent's _id
Messages of a ticket, orders of a customer
Subset + reference
Reference, plus the newest or most relevant few children copied into the parent
Product with its five latest reviews
Extended reference
Reference, plus a few fields of the referenced document copied next to the id
Ask them for one relationship at a time. The order matters because early answers rule options out.
1. Is the number of children bounded by something real? Not "small today" but limited by the domain: a person has a few addresses, an order has the items in a basket. If nothing limits the count (events, messages, followers), embedding all of them is ruled out because a document cannot exceed 16 MiB and degrades long before that. Go to reference, and use question 3 to decide whether to add a subset.
2. Is the child needed without its parent? If it is queried by its own fields across parents, listed on its own screen, or shared by several parents, it is an entity with its own life and gets its own collection. A product belongs to many orders. A comment that appears in a moderation queue across all posts is queried without its post.
3. Is the child read with the parent on a frequent path? If yes, and questions 1 and 2 allowed it, embed. If yes but you were already sent to a reference, add a copy: a subset for one-to-many, an extended reference for many-to-one. If no, a plain reference is enough and a copy would be wasted work.
4. How often does the copied or embedded data change, and in how many documents would it live? Multiply the two. A country name copied into a million addresses changes almost never: fine. A product price copied into every open cart changes weekly across thousands of carts: think again. High change rate times high fan-out means reference only and pay for the join.
5. Must parent and child change atomically? A write to one document is atomic. If a status change and its history entry must succeed or fail together, embedding gives you that for free. If the answers above forced a reference, you need a transaction or a design that tolerates a partial write.
6. How large does the parent become, and how often is it read without the child? Embedding a 40 KB specification sheet in a product that is mostly read in listings makes every listing read carry it through the cache. A bounded, parent-only child can still deserve its own document for this reason alone.
7. How will this shard? Embedded data is on the same shard as its parent by definition. Referenced children are placed by their own shard key. If the children's key starts with the parent id, a query for one parent's children is routed to the shard or shards that own that range. If it does not, the same query is broadcast to every shard.
Answers
Outcome
Bounded, not independent, read together
Embed
Unbounded or independent, not read together
Reference
Unbounded, read together (a few at a time)
Subset + reference
Independent, read together, changes rarely
Extended reference
Independent, read together, changes often with wide fan-out
Reference, and accept $lookup or a second query
Bounded and read together, but large and cold
Reference with the same _id in a second collection
What each choice costs
Single-document atomicity against transactions
WiredTiger applies all the changes in a single update to one document atomically, with document-level concurrency control. No session, no retry logic, no coordination.
A multi-document transaction gives the same guarantee across documents at a price the manual spells out: it costs more than single-document writes and is not a replacement for a schema that keeps related data together. The concrete constraints:
A transaction must finish within transactionLifetimeLimitSeconds, 60 seconds by default, or it is aborted.
Operations in a transaction wait only 5 milliseconds by default to acquire a lock before the transaction aborts.
Uncommitted changes add pressure on the WiredTiger cache. A transaction that creates too much pressure is aborted with a write conflict error.
Two transactions that modify the same document conflict. One aborts with a transient error and has to be retried, which the driver's callback API does for you.
On a sharded cluster, a transaction that writes to more than one shard goes through a two-phase commit.
Short transactions over a handful of documents are routine and fine. A design in which every ordinary write needs one is a sign that the documents are split in the wrong place.
The cost of $lookup
A reference has to be resolved on read, either with a second query from the application or with $lookup. Each joined document is another index traversal and another document fetch, where the embedded version had none. Three rules keep that affordable:
Index the foreign field. The manual warns that an equality $lookup without one is likely to perform poorly.
Reduce the input first. Put $match, $sort and $limit before $lookup so that 20 documents are joined and not 20,000.
Check version constraints on sharded clusters. The from collection can be sharded only from MongoDB 5.1, and $lookup inside a transaction can target a sharded collection only from 8.0.
A second query by _id with $in is often as fast as $lookup and easier to reason about. Either way, a join on a screen that is opened a few times a minute is not a problem. The same join on the busiest read is.
Duplicated data and keeping it in sync
Subsets and extended references are copies. Decide per copied field which of these applies and write it down:
Strategy
How
Use when
Never update
The copy is a snapshot
The value at the time of the event is the correct one: price on an order line, address on a shipped parcel
Update in the same transaction
Source and copies change together
Fan-out is small and readers must never see a mismatch
Update asynchronously
A change stream consumer, an Atlas trigger or a job applies updateMany after the source changes
Fan-out is large and a short period of stale data is acceptable
Reconcile periodically
A scheduled job compares and repairs
As a safety net for the asynchronous route
Whichever you choose, the fan-out update needs an index on the copied id ({ "customer._id": 1 }) or it scans the collection every time a name changes.
Document size and the working set
The server reads, caches and replicates whole documents. Embedding makes the parent bigger, which means fewer parents fit in the WiredTiger cache and every read of the parent moves more bytes. That is the right trade when the embedded data is used on most reads of the parent, and the wrong one when it is not. A projection does not rescue it: the document is still loaded in full unless an index covers the query.
A worked example: a help desk
The application has tickets, the messages in each ticket's thread, customers, support agents, tags, attachments and a status history. The frequent operations are:
The agent queue: open tickets for a team, sorted by priority and last update, showing subject, customer name, assignee name, tags and a preview of the last message. Refreshed constantly.
The ticket view: one ticket with its thread, a page of messages at a time.
Adding a message to a ticket.
Changing status or assignee.
Take each relationship through the questions.
Ticket and messages. Question 1: unbounded. A thread can run to hundreds of messages with long bodies. Reference: messages get their own collection with ticketId. Question 3: the queue needs a preview of the last message and a count, on the most frequent read in the system. Result: subset + reference, where the subset is one message preview and a computed count.
Ticket and customer. Question 2: customers exist on their own, with their own screens. Reference. Question 3: the queue shows the customer's name. Question 4: names change rarely, and a customer has tens of tickets, so the fan-out is small. Result: extended reference with _id and name.
Ticket and assignee. Same reasoning, except the fan-out is thousands of tickets per agent. A rename is rare enough that an asynchronous updateMany is acceptable. Result: extended reference, updated asynchronously.
Ticket and tags. Many-to-many. Question 1: a ticket carries a handful of tags, so the ticket side is bounded, while a tag's list of tickets is not. Question 4: a tag's display label might be edited, with a very wide fan-out. Result: embed an array of stable tag keys in the ticket and keep labels in a small tags collection that the client caches. The array goes on the bounded side and nothing on the unbounded side.
Ticket and status history. Question 1: bounded in practice, since a ticket changes state a handful of times. Question 5: a status change and its history entry must not diverge. Result: embed, with a $slice cap as a guard against a misbehaving integration.
Message and attachments. Bounded, never read without the message, small metadata. Result: embed the metadata, with the file itself in object storage.
The queue is now one query on one collection with no join. The ticket view is two indexed queries: the ticket by _id and a page of messages by ticketId.
A status change touches only the ticket, so it is atomic without a transaction:
Including the expected current status in the filter makes the update a compare-and-set: if another agent changed the status first, matchedCount is 0 and the application can reload.
Adding a message writes two documents. If the queue must never show a preview that disagrees with the thread, wrap them in a transaction:
const session = db.getMongo().startSession();
const helpdesk = session.getDatabase("helpdesk");
const ticketId = ObjectId("66f2a1c4e13b9a0d5c7f1a01");
const now = newDate();
session.withTransaction(() => {
helpdesk.messages.insertOne({
ticketId: ticketId,
from: "agent",
authorId: ObjectId("66f29e02e13b9a0d5c7f0a07"),
body: "Thanks Ana, I have corrected the rate and reissued the invoice.",
attachments: [],
at: now
});
helpdesk.tickets.updateOne(
{ _id: ticketId },
{
$set: {
lastMessage: { from: "agent", preview: "Thanks Ana, I have corrected the rate", at: now },
updatedAt: now
},
$inc: { messageCount: 1 }
}
);
}, { writeConcern: { w: "majority" } });
session.endSession();
This is a small, short transaction on two documents, which is the case transactions handle well. The alternative is to insert the message first and update the ticket second without a transaction, and let a periodic job repair lastMessage and messageCount from the messages collection. That is cheaper per write and leaves a brief window in which the queue is behind.
If messages is ever sharded, a shard key that begins with ticketId lets the thread query be routed by ticket.
Common mistakes
Stopping at question 1. "It's one-to-many, so reference" skips the read pattern and produces a join on the busiest screen.
Embedding on the unbounded side of a many-to-many. The array belongs on the side with a natural limit.
Copying fields without choosing a sync strategy. The copy drifts, and nobody notices until a customer does.
Updating a snapshot. Rewriting the price on historical order lines after a price change corrupts the record. Decide which copies are frozen.
Using a transaction for every write. It works in testing and shows up later as write conflicts and aborted transactions under load.
Forgetting the index for the fan-out update.updateMany on assignee._id without an index is a collection scan per rename.
Embedding large cold data because it is one-to-one. Cardinality allows it. Question 6 may still say no.
Checklist
List the relationships and take each through the seven questions separately.
For every array, state what bounds it. Add a $slice cap where the bound is "in practice".
For every reference read on a frequent path, decide between a subset, an extended reference or paying for the join.
For every copied field, record the change rate, the fan-out and the sync strategy: frozen, transactional, asynchronous or reconciled.
Identify the writes that span documents and decide for each whether it needs a transaction or tolerates a repairable partial write.
Index every foreign field used by $lookup, by child queries and by fan-out updates.
Check the largest embedded field against how often the parent is read without it.
If sharding is likely, make sure child collections can be queried by a key that starts with the parent id.
The 100 MB blocking-sort limit means your index doesn't provide the sort order. Build compound indexes with the ESR rule, order pipeline stages correctly, and know when allowDiskUse is fine.