What you'll learn
Quick Answer
MongoDB schema design means deciding, for each relationship, whether to embed the related data inside the parent document or store it in another collection and reference it by _id. Embed when the child is small, bounded and always read with the parent. Reference when it is queried on its own, shared between parents, or can grow without limit. Every document has a hard 16MB ceiling, so any array that grows forever must live in its own collection.
You have a schema whether you declare one or not
The pitch for MongoDB is that it is schemaless. That is true of the server and false of your application. Your code always assumes a shape. The only question is whether that shape is written down in one place or scattered across every file that touches the collection.
Here is how it goes wrong. The signup form saves pincode as a string because that is what an HTML input gives you. Six months later, an import script saves it as a number. Both inserts succeed. Then this query returns half the students:
db.students.find({ pincode: 411001 }) // misses every document storing "411001"
MongoDB compares by type as well as value, so 411001 and "411001" are different keys and different index entries. No error, no warning, just a report that is quietly wrong. The same thing happens with dates stored as ISO strings in one place and real Date objects in another, which also breaks range queries and sorting.
Fix it at the collection, not only in the ORM. MongoDB has built-in validation:
db.createCollection("students", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["name", "pincode"],
properties: {
name: { bsonType: "string" },
pincode: { bsonType: "string", pattern: "^[1-9][0-9]{5}$" }
}
}
}
})
Mongoose schemas are useful too, but they only bind writes that go through Mongoose. A migration script, a mongosh session or a second service in another language will walk straight past them. The validator lives with the data.
Embed vs reference
This is the whole subject in one decision, repeated for every relationship. Unlike SQL, where normalisation gives you a default answer, MongoDB asks you to model around the queries you actually run.
Embed when the child data is read with the parent, owned by the parent, and bounded in size. A student's address is a good example. You never query addresses on their own, an address belongs to exactly one student, and there is one of them.
{
_id: ObjectId("..."),
name: "Ananya Rao",
address: { line1: "12 MG Road", city: "Pune", pincode: "411001" },
enrolledCourseIds: [ ObjectId("..."), ObjectId("...") ]
}
Reference when the child is queried independently, shared by many parents, or unbounded. Courses fit all three: you list courses on their own page, thousands of students share one course, and you do not want to rewrite every student document when a course title changes.
The pull towards embedding is that it removes a round trip. One query, one document, everything the page needs. That is a real advantage and it is why denormalised documents often beat a normalised design for read-heavy screens.
The pull away from it is that documents are read and written whole. Even if you project a single field, the server generally still pulls the whole document into memory to satisfy the query, unless every field involved sits in one index and it can answer from the index alone. Updating one flag on a 2MB document means the storage engine writes a new version of the whole thing. A document that grows steadily makes every unrelated operation on it slower, and nothing in your code will point at the cause.
A rough rule that survives contact with real projects: one-to-few embeds, one-to-many references, one-to-squillions always references.
The 16MB limit and unbounded arrays
A single BSON document cannot exceed 16MB. This is a hard server limit, not a setting you can raise.
The failure mode is specific and nasty. Embedding comments inside a blog post works beautifully for a year. Then one post gets popular, the array keeps growing, and an ordinary $push starts failing with a document-too-large error. It fails on your most successful document, in production, and only there. Every other post is fine, so it looks like data corruption rather than a design decision made twelve months earlier.
Long before you reach 16MB you are already suffering. A post document with 8,000 comments has to be loaded in full to render the title. Sorting or paginating inside an embedded array is awkward. Memory use per query climbs.
So: never embed anything a user can add to without limit. Comments, chat messages, audit log entries, sensor readings, order history. Those go in their own collection with a reference back:
// comments collection
{ _id: ObjectId("..."), postId: ObjectId("..."), body: "...", createdAt: ISODate("...") }
db.comments.createIndex({ postId: 1, createdAt: -1 })
If you want the speed of embedding for the common case, keep a bounded copy. $slice caps an array at write time, so the parent holds the most recent few while the full set lives elsewhere:
db.posts.updateOne(
{ _id: postId },
{ $push: { recentComments: { $each: [comment], $slice: -20 } } }
)
A negative $slice keeps the last N elements. The post page renders from one document, and "view all comments" hits the comments collection. For high-volume time-series data the same idea appears as the bucket pattern: one document per device per hour holding an array of readings, rather than one document per reading.
Denormalisation and keeping copies honest
Denormalisation means deliberately storing the same value in two places so a read does not need a join. Storing authorName on each post saves a lookup on every listing page.
The cost is drift. The author edits their name, and four thousand posts still carry the old one. There is no foreign key to catch it and no constraint to enforce it. You have taken on a job the database used to do for you.
Two questions make the decision easy. First, does this value change? A course title changes rarely; a live seat count changes constantly. Copy the first, never the second. Second, should the copy change? This is the case people miss. The price on an order line is not a stale copy of the current price, it is the price the student actually paid. Copying it is not denormalisation at all, it is correctness. The same goes for the name printed on a certificate.
When you do copy mutable data, copy the smallest useful slice and keep the reference:
{
_id: ObjectId("..."),
title: "Why I Learned SQL First",
author: { _id: ObjectId("..."), name: "Ananya Rao" } // extended reference
}
Then write the fix-up path on day one, not after the first complaint: when an author updates their name, run db.posts.updateMany({ "author._id": id }, { $set: { "author.name": newName } }). That is only cheap if author._id is indexed, so create db.posts.createIndex({ "author._id": 1 }) at the same time you start storing the copy. MongoDB does support multi-document transactions on replica sets, and they are useful for exactly this kind of paired write, but a transaction is a patch over a schema that duplicates too much, not a licence to duplicate more.
Modelling relationships and indexing nested fields
For one-to-many, put the reference on the many side. Each comment stores a postId. Do not keep an array of comment ids on the post, because that array is unbounded again and you have gained nothing.
For many-to-many, store the array on whichever side you query from, and keep it small. Students to courses: an enrolledCourseIds array on the student is fine, because a student enrols in tens of courses, not millions. The reverse direction, "who is enrolled in this course", is answered by an index on that array rather than by a second array on the course. If both sides are unbounded, or the relationship carries its own data such as marks or a joining date, make it a third collection of enrolment documents.
Indexes on nested fields use a dotted path:
db.students.createIndex({ "address.city": 1 })
db.students.find({ "address.city": "Pune" }) // uses the index
Here is the gotcha that costs people an afternoon. This looks equivalent and is not:
db.students.find({ address: { city: "Pune" } }) // almost never what you want
That is an exact subdocument match. It only matches documents whose address contains exactly those fields, in exactly that order. Add a pincode and the match disappears. Always query with the dotted path.
Indexing an array field creates a multikey index, with one index entry per element, so { enrolledCourseIds: 1 } works and matching a single id is fast. The limit to remember is that a compound index may include at most one array field, which quietly constrains how much you can embed and still index well. And $lookup exists if you need a join in an aggregation, but if you find yourself reaching for it on every request, that is the schema telling you the two collections wanted to be one document.
