When should I embed documents vs use references in MongoDB? · DpkDB
When should I embed documents vs use references in MongoDB?
Twelve common MongoDB relationships, from user addresses to org charts, each with an embed, reference or hybrid verdict, the document shape and the reason.
Embed when the child data is bounded, belongs to exactly one parent and is read with it. Use a reference when the children can grow without limit, are queried on their own, or are shared between parents. When a relationship is unbounded but a few fields are needed on every read, do both: reference the full data and embed a small, capped copy. The twelve cases below are the ones asked about most often, each with a verdict, the document shape and the reason.
A complete schema for a multi-tenant project tracker: collections, types, ESR indexes checked with explain(), validation, migrations, write concerns and sharding readiness.
A person has a few addresses, they are edited on the user's own screen and nobody queries addresses across users. Update one in place with the positional operator: db.users.updateOne({ _id: 811, "addresses.label": "office" }, { $set: { "addresses.$.line1": "Gran Via 30" } }). It changes if addresses become shared entities, for example company sites used by many employees.
2. Blog post and comments: hybrid
db.posts.insertOne({
_id: 42,
title: "Choosing an index order",
body: "...",
commentCount: 318,
topComments: [
{ _id: 9001, author: "li", text: "The ESR section cleared this up.", at: ISODate("2025-02-11T10:04:00Z") }
]
})
db.comments.insertOne({
_id: 9001, postId: 42, author: "li", text: "The ESR section cleared this up.",
at: ISODate("2025-02-11T10:04:00Z")
})
db.comments.createIndex({ postId: 1, at: 1 })
Nothing limits comments on a popular post, and moderation needs to query comments across posts, so they get a collection. The post keeps a count and a capped preview so the first render is one read. A blog whose posts never receive more than a few comments can embed them all, as long as something enforces the cap.
Line items have no meaning outside their order, the basket bounds their number, and the order with its items must be written atomically. The name and unitPrice are copied from the product on purpose: they record what was sold and are never updated. productId stays as a reference for reporting. Wholesale orders with tens of thousands of lines are the exception and need a line collection.
Reviews are unbounded and are also listed per user, so they are referenced from a reviews collection. The product page needs the average and the latest few on every view, so those are embedded and maintained on write with $inc and a $push that uses $slice.
5. Product and categories or tags: reference from the product
In a many-to-many relationship, put the array of ids on the side whose list is short. A product is in a few categories. A category can contain a hundred thousand products, so an array of product ids in the category would be unbounded. The multikey index answers "products in this category" directly. Embedding the category name in each product would mean rewriting every product when a category is renamed, so only the stable id is stored.
6. Users and followers: reference in an edge collection
Neither side is bounded: a popular account has millions of followers and an active one follows thousands. One document per relationship scales to any size. The unique index prevents duplicates and answers "whom do I follow", and the reversed index answers "who follows me". Counts are kept on the user so that profile pages do not count index entries.
Messages are unbounded and always read a page at a time, newest first, which the compound index serves without a sort. The room keeps only lastMessage for the room list. memberIds can stay embedded for small groups. A public channel with unlimited members needs a memberships collection for the same reason followers do.
8. IoT readings: reference, in a time series collection
A device produces readings for as long as it runs, so an array on the device document is the standard unbounded-array failure. Readings go in their own collection keyed by device and time. From MongoDB 5.0 a time series collection groups and compresses them for you. Keep only the latest reading on the device document if dashboards need it.
9. Audit or activity log: reference, with a frozen copy of context
Events are unbounded, so they are never embedded in the thing they describe. Inside each event, embed the actor's name and role as they were at that moment. An audit record that resolves the actor through a reference shows today's name and role, which is not what happened. Entries are inserted and never updated.
A one-to-one relationship that is read on every request belongs in the same document. A separate user_settings collection adds a second query to every page for no benefit. The exception is a large part of the profile that is rarely read, such as a long biography or binary image data. Move that out, and store images in object storage with a URL in the user.
11. Invoice and customer data: embed a snapshot, keep the id
An invoice is a legal record of a sale. If the customer moves next year, the invoice must still show the old address, so the billing details are copied in at issue time and never synchronized. This is duplication that is correct by design. customerId remains for "all invoices of this customer".
12. Org chart or category tree: parent reference plus ancestors
Each node is its own document because a node is queried, moved and edited independently, and nesting the tree in one document runs into the 100-level nesting limit and makes every edit a rewrite of the whole tree. managerId gives direct reports. The ancestors array, bounded by the depth of the tree, returns everyone under a manager from one indexed query. The cost is that moving a manager means updating ancestors for the whole subtree. Other tree models are compared in MongoDB data modeling: the patterns you will actually use.
What the verdicts have in common
Three observations cover all twelve.
Every embedded array has a named bound. Addresses per person, lines per basket, depth of a tree. Where the bound is a policy and not a fact of the domain (top comments, recent reviews), the write enforces it with $slice.
Every unbounded relationship lives in a collection whose documents hold the parent id, with a compound index that starts with that id and ends with the sort field.
Every copy is classified. It is either a cache that must be refreshed when the source changes (a comment preview, a last message) or a snapshot that must never be refreshed (invoice details, audit actors, order prices). Mixing the two up is the most damaging mistake on this page.
Common mistakes
Embedding because the current data is small. Comments, messages and readings look bounded in a test dataset.
Putting the id array on the wrong side of a many-to-many. Categories holding product ids, or users holding follower ids, grow without limit.
Referencing one-to-one data out of relational habit. It doubles the reads for the most frequent request.
Refreshing snapshots. A job that "fixes" customer names on old invoices destroys the record.
Not refreshing caches. A lastMessage preview that survives the deletion of its message is a visible bug.
Resolving audit context by reference. The log then reports current state, not history.
Forgetting the index on the reference. Every postId, roomId and productId used to fetch children needs a compound index, or each page load scans the collection.
Checklist
Find your case in the verdict table, or the closest one.
For each array in your documents, name what bounds it, and add $slice where the bound is a policy.
For each child collection, create the compound index of parent id plus sort field.
Put many-to-many id arrays on the side with the short list. Use an edge collection when both sides are long.
Label every duplicated field as cache or snapshot, and write the refresh path for each cache.
Use Decimal128 for money and Date for time in every shape above.
Use a time series collection for timestamped measurements.
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.