MongoDB Schema Design: Embed or Reference?
The embed-versus-reference decision, the 16 MB document limit, and five schema patterns that solve the problems normalisation would have solved in SQL.
Table of contents
- Embed when the data is read together and bounded
- Reference when growth is unbounded
- The five patterns worth knowing
- Use schema validation anyway
- Frequently asked questions
- Should I ever use $lookup?
- How do I model many-to-many?
- Is denormalisation not just duplication?
- What about transactions?
- Related reading
- References
MongoDB has no joins worth relying on, so the schema decision that matters is: does this related data live inside the document or in another collection?
The answer follows from how you read the data, not from how it is structured conceptually.
Embed when the data is read together and bounded#
// A user and their addresses: read together, small, always relevant
{
_id: ObjectId('...'),
email: 'ada@example.com',
addresses: [
{ label: 'home', line1: '...', postcode: '...' },
{ label: 'work', line1: '...', postcode: '...' },
],
}One read gets everything. No join, no second round-trip.
Embed when all three hold:
- The child is almost always needed with the parent.
- The number of children is bounded and small (tens, not thousands).
- The child has no independent identity — you never query addresses on their own.
Reference when growth is unbounded#
// posts
{ _id: ObjectId('p1'), title: '...', authorId: ObjectId('u1') }
// comments — could be 50,000 on one post
{ _id: ObjectId('c1'), postId: ObjectId('p1'), body: '...' }Embedding comments would fail on all three tests: a post page may paginate them, the count is unbounded, and comments are queried and moderated independently.
The hard limit forcing the issue is 16 MB per document. An unbounded array eventually breaks the document entirely — and it degrades long before that, because MongoDB must rewrite and move the whole document as it grows.
The five patterns worth knowing#
Extended reference — duplicate the few fields you always display, to avoid a lookup:
{
_id: ObjectId('o1'),
customerId: ObjectId('u1'),
// Denormalised so the order list needs no second query
customer: { name: 'Ada Lovelace', email: 'ada@example.com' },
}The cost is that a name change must update both places. Accept it when the field rarely changes and the read is frequent — which is exactly the trade MongoDB is built for.
Computed pattern — store the aggregate rather than recomputing it:
{ _id: ObjectId('p1'), title: '...', commentCount: 1_284, avgRating: 4.3 }Recomputing a count on every page view is the mistake this prevents.
Bucket pattern — for time series, group many readings into one document:
{
sensorId: 'temp-1',
day: '2026-07-14',
readings: [ { t: '00:00', v: 21.2 }, /* ...1439 more */ ],
}1,440 documents per sensor per day become one. Dramatically fewer index entries and far less overhead.
Subset pattern — embed the first N, reference the rest:
{
_id: ObjectId('p1'),
title: '...',
recentComments: [ /* the 5 shown on the page */ ],
commentCount: 1_284,
}The page renders from one read; "load more" queries the comments collection.
Schema versioning — put a version field on every document from day one:
{ _id: ObjectId('...'), schemaVersion: 2, /* ... */ }This is the pattern that makes migrations survivable in a schemaless store. Without it you cannot tell a v1 document from a v2 one, and you will need to.
Use schema validation anyway#
"Schemaless" is a property of the database, not a goal. Enforce shape with $jsonSchema validation on the collection, or with Mongoose schemas in the application — ideally both, so a stray script cannot write a malformed document.
Frequently asked questions#
Should I ever use $lookup?#
It exists and it works, but it is not as optimised as a SQL join and it cannot use an index on the joined collection as effectively. Occasional reporting: fine. A hot read path: design it away.
How do I model many-to-many?#
An array of references on the side with fewer, more stable relations — usually tags on a post rather than posts on a tag. If both sides are unbounded, use a join collection as you would in SQL.
Is denormalisation not just duplication?#
Yes, deliberately. You are trading write complexity and storage for read speed. Make it a conscious decision per field, and document which copy is authoritative.
What about transactions?#
MongoDB supports multi-document ACID transactions. They work, and they are slower than a single-document write — which is another argument for embedding data that must change atomically.
Related reading#
- MongoDB Indexes
- SQL Joins Explained — the model MongoDB is deliberately not
- Inspect a document with the JSON Formatter.