Embedding vs referencing, polymorphic patterns, bucket pattern — design documents right.
Published September 21, 2026
MongoDB doesn't force a schema, but every application has one: it's implied by how your code reads and writes documents. A good MongoDB schema isn't derived from the entity–relationship diagram, as in a relational design. It's derived from the access patterns: which data is read together, how often, how it grows, and which operations must be atomic.
The guiding rule is: data that is accessed together should be stored together, as long as the document stays bounded in size.
Embedding puts related data inside the parent document:
{
"_id": "u_1",
"name": "Asha",
"addresses": [
{ "label": "home", "city": "Pune", "pin": "411001" },
{ "label": "work", "city": "Mumbai", "pin": "400051" }
]
}
$lookup.Referencing stores an ID and fetches the related document separately:
{ "_id": "o_981", "userId": "u_1", "total": 129.49, "status": "PAID" }
$lookup.How to decide:
| Relationship / access | Usually |
|---|---|
| One-to-few, always read with the parent (addresses, phone numbers) | Embed |
| One-to-many, bounded, read together (order → line items) | Embed |
| One-to-many, large or growing without limit (post → comments, user → orders) | Reference (child stores parent ID) |
| Many-to-many (students ↔ courses) | Reference, often arrays of IDs on the smaller side |
| Data needed independently, or shared by many parents | Reference |
| Data that changes much more often than its parent | Often reference |
Unbounded arrays are the classic anti-pattern. An array that grows forever (all comments, all log events) eventually hits the size limit, makes every update rewrite a large document, and bloats indexes.
Store a reference plus a few frequently needed fields from the referenced document:
{
"_id": "o_981",
"customer": { "id": "u_1", "name": "Asha" },
"items": [ { "sku": "KB-01", "name": "Mechanical Keyboard", "price": 89.99, "qty": 1 } ]
}
Order lists can now show the customer name and item names without a join. Choose fields that rarely change, or whose historical value is actually correct. The price at purchase time should be frozen on the order.
Keep the most recent or most relevant few children embedded, and the rest in a separate collection. A product page shows the latest 10 reviews embedded in the product document, and "see all reviews" queries the reviews collection. The document stays small, and the common read stays a single fetch.
Instead of one document per measurement, group measurements into a bucket per entity and time window:
{
"sensorId": "s_42",
"start": { "$date": "2026-01-15T10:00:00Z" },
"count": 60,
"readings": [ { "t": 0, "temp": 22.1 }, { "t": 60, "temp": 22.3 } ],
"sum": 1330.2, "min": 21.8, "max": 22.5
}
This means fewer documents and index entries, and range queries touch fewer documents. The precomputed sum, min and max answer summary queries without reading every reading. Insert with an upsert that $pushes into the current bucket while count < 60. For new projects, MongoDB's native time-series collections apply this bucketing automatically.
Store values that are expensive to compute and read often (review count, average rating, total spent), and update them when the underlying data changes, either in the same write with $inc or asynchronously. This trades a little write work for much cheaper reads.
Design for the typical case, and handle the rare huge one separately. Most books have a handful of authors to embed. A few anthologies have hundreds. Embed up to a limit, then set a flag such as hasMore: true and store the overflow in another collection. Only the outliers pay the extra cost.
Store different but related shapes in one collection with a discriminator field:
{ "_id": 1, "type": "BOOK", "title": "…", "isbn": "…", "pages": 320 }
{ "_id": 2, "type": "COURSE", "title": "…", "durationHrs": 12, "level": "ADVANCED" }
You can query shared fields across all items, such as a catalogue search on title, while each type keeps its own fields. Spring Data maps these to a class hierarchy using the stored _class type hint.
Documents written at different times may have different shapes. Add a schemaVersion field and handle each version when reading, migrating lazily (rewrite a document on its next update) or with a background job:
User toUser(Document doc) {
int version = doc.getInteger("schemaVersion", 1);
if (version == 1) { // v1 stored a single "name" string
String name = doc.getString("name");
int space = name.indexOf(' ');
return space < 0 ? new User(name, "")
: new User(name.substring(0, space), name.substring(space + 1));
}
return new User(doc.getString("firstName"), doc.getString("lastName"));
}
Handle names without a space, which is a real edge case that naive split(" ")[1] code crashes on.
Flexible doesn't mean unvalidated. MongoDB's $jsonSchema validator can require fields and types at the database level, protecting against bugs in any client:
db.createCollection("orders", { validator: { $jsonSchema: {
bsonType: "object",
required: ["customer", "items", "status"],
properties: { status: { enum: ["PENDING", "PAID", "SHIPPED", "CANCELLED"] } }
}}})
Q: What's the most common MongoDB schema mistake? A: Unbounded arrays: embedding children that grow without limit, such as all comments or all events. They approach the 16 MB limit, make every write rewrite a large document, and slow down reads. Reference them, or use the subset or bucket pattern.
Q: When do you need multi-document transactions in MongoDB? A: When an operation must atomically change data that lives in separate documents, such as transferring between two accounts. Good schema design minimizes that by keeping data that changes together in one document, because single-document writes are always atomic and much cheaper.
Q: How do you model a many-to-many relationship? A: Usually with arrays of references on the side where the list is bounded (a student's enrolled course IDs), or with a separate linking collection when both sides are large. Duplicate a few display fields if listing pages need them.
Q: How is MongoDB schema design different from relational normalization? A: Relational design normalizes first and joins at read time. MongoDB design starts from access patterns and often embeds or duplicates data to serve reads in a single document, accepting the work of keeping copies in sync.
Q: How would you handle a schema change on millions of documents? A: Make the application read both old and new shapes (with a version field), write the new shape going forward, and migrate old documents lazily on update or with a throttled background job. Once everything is migrated, remove the old-shape code.