MongoDB's flexible schema is a feature, not just a convenience. Good schema design is the single most important factor in MongoDB performance. This lesson covers the fundamental decision: embed or reference, and how to model real-world relationships.
Every relationship in MongoDB can be modeled in two ways: embed the related data inside the document, or reference it by storing an ObjectId pointing to another collection.
| Factor | Embedding (Denormalized) | Referencing (Normalized) |
|---|---|---|
| Read performance | Fast — single document fetch | Slower — requires $lookup or 2 queries |
| Write performance | Update embedded = update parent | Update referenced = update 1 document |
| Data duplication | Yes — same data repeated | No — single source of truth |
| Data consistency | Manual update if data changes | Automatic — one place to update |
| Document size | Can grow large (16MB limit) | Small, predictable |
| Atomicity | Atomic (single doc operation) | Multi-doc (needs transactions) |
| Query simplicity | Simple — just access the field | Complex — need $lookup or populate |
| Best when | Data accessed together, 1:1 or 1:Few | Data accessed independently, 1:Many or M:N |
A user has exactly one profile. The profile is never fetched without the user. This is the ideal case for embedding.
-- users table users: id, email, password -- profiles table profiles: id, user_id, first_name, last_name, avatar, bio
{
_id: ObjectId(...),
email: "alice@example.com",
password: "$2b$12$...",
profile: {
firstName: "Alice",
lastName: "Johnson",
avatar: "https://...",
bio: "Developer..."
}
}
// Access embedded profile — no join needed db.users.findOne({ email: "alice@example.com" }) // Returns the whole doc including profile // Query by nested field db.users.find({ "profile.firstName": "Alice" }) // Update a nested field db.users.updateOne( { email: "alice@example.com" }, { $set: { "profile.bio": "Senior Developer & MongoDB enthusiast" } } )
Use when the "many" side is small (fewer than 100 items), grows slowly, and is always accessed with the parent. Example: a post's comments.
// Post document with embedded comments { _id: ObjectId("post1"), title: "MongoDB Schema Design", author: "alice", body: "Today we learn about schema design...", comments: [ { _id: ObjectId(), author: "bob", text: "Great post!", createdAt: ISODate("2024-01-15") }, { _id: ObjectId(), author: "carol", text: "Very helpful, thanks!", createdAt: ISODate("2024-01-16") } ] } // Add a new comment db.posts.updateOne( { _id: ObjectId("post1") }, { $push: { comments: { _id: new ObjectId(), author: "dave", text: "Bookmarking this!", createdAt: new Date() } } } ) // Find posts with a comment from "bob" db.posts.find({ "comments.author": "bob" })
Use when the "many" side can be large, is frequently accessed independently, or needs its own indexes. Example: orders for a customer.
// Customer document (the "one" side) { _id: ObjectId("customer1"), name: "Alice Johnson", email: "alice@example.com" } // Order documents (the "many" side — each references the customer) { _id: ObjectId("order1"), customerId: ObjectId("customer1"), // ← the reference items: [{ productId: ObjectId("p1"), qty: 2, price: 19.99 }], total: 39.98, status: "shipped", createdAt: ISODate("2024-02-01") } // Find all orders for a customer db.orders.find({ customerId: ObjectId("customer1") }) // Lookup orders when fetching customer (aggregation) db.customers.aggregate([ { $match: { _id: ObjectId("customer1") } }, { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } } ])
// Author stores an array of post IDs { _id: ObjectId("author1"), name: "Alice Johnson", postIds: [ ObjectId("post1"), ObjectId("post2"), ObjectId("post3") ] } // Fetch all posts by IDs db.posts.find({ _id: { $in: authorDoc.postIds } })
Students can enroll in many courses; courses have many students. There are two common approaches:
// Student document { _id: ObjectId("student1"), name: "Bob Smith", enrolledCourseIds: [ ObjectId("course1"), ObjectId("course2") ] } // Course document { _id: ObjectId("course1"), title: "MongoDB Fundamentals", enrolledStudentIds: [ ObjectId("student1"), ObjectId("student3") ] } // Find all courses a student is in db.courses.find({ _id: { $in: student.enrolledCourseIds } }) // Find all students in a course db.students.find({ _id: { $in: course.enrolledStudentIds } })
Use when the relationship itself has data (e.g., enrollment date, grade).
// Enrollment junction document { _id: ObjectId(), studentId: ObjectId("student1"), courseId: ObjectId("course1"), enrolledAt: ISODate("2024-01-10"), grade: null, completed: false } // Find all courses with student info (aggregation) db.enrollments.aggregate([ { $match: { studentId: ObjectId("student1") } }, { $lookup: { from: "courses", localField: "courseId", foreignField: "_id", as: "course" } }, { $unwind: "$course" } ])
// USERS collection — referenced from posts { _id: ObjectId("u1"), username: "alice_dev", email: "alice@example.com", password: "$2b$12$hashed...", role: "author", bio: "Software developer...", avatar: "https://...", createdAt: ISODate("2023-06-01") } // POSTS collection — author referenced, comments embedded { _id: ObjectId("p1"), title: "MongoDB Schema Design", slug: "mongodb-schema-design", body: "Full article text...", authorId: ObjectId("u1"), // ← reference (user changes name? Update one place) tags: ["mongodb", "design"], status: "published", views: 1240, likes: 84, comments: [ // ← embedded (always read with post) { _id: ObjectId(), authorId: ObjectId("u2"), authorName: "Bob", // ← denormalized for display performance text: "Great article!", createdAt: ISODate("2024-01-15") } ], publishedAt: ISODate("2024-01-10"), updatedAt: ISODate("2024-01-12") } // TAGS collection — referenced by posts { _id: ObjectId(), name: "mongodb", slug: "mongodb", postCount: 42 }
// PRODUCTS collection { _id: ObjectId("prod1"), name: "Mechanical Keyboard", sku: "KB-MX-001", price: 149.99, category: "electronics", stock: 23, attributes: { brand: "Keychron", switchType: "Brown", layout: "TKL" }, images: ["img1.jpg", "img2.jpg"] } // ORDERS collection — snapshot pattern (embed product details) { _id: ObjectId("ord1"), userId: ObjectId("u1"), status: "processing", items: [ { productId: ObjectId("prod1"), // Snapshot fields — price at time of purchase name: "Mechanical Keyboard", sku: "KB-MX-001", price: 149.99, // ← embedded price (won't change if product price changes) quantity: 1 } ], shipping: { address: "123 Main St, New York, NY 10001", method: "standard", estimatedDelivery: ISODate("2024-02-15") }, subtotal: 149.99, tax: 13.50, total: 163.49, createdAt: ISODate("2024-02-10") }
// BAD: comments can grow to millions { _id: postId, title: "...", comments: [ /* 1,000,000 comments */ ] } // GOOD: separate comments collection { _id: commentId, postId: postId, text: "...", author: "..." }
// BAD: 5 levels of nesting { a: { b: { c: { d: { e: "value" } } } } } // Query: db.col.find({ "a.b.c.d.e": "value" }) — messy! // GOOD: flatten where possible { aValue: "value", category: "...", type: "..." }
// BAD: one collection per user db.user_alice_posts, db.user_bob_posts ... // GOOD: one collection with a userId field db.posts.find({ userId: "alice" })
MongoDB supports optional schema validation using JSON Schema. This enforces rules at the database level.
// Create a collection with validation rules db.createCollection("users", { validator: { $jsonSchema: { bsonType: "object", required: ["email", "password", "role"], properties: { email: { bsonType: "string", pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", description: "must be a valid email address" }, password: { bsonType: "string", minLength: 8, description: "must be at least 8 characters" }, role: { bsonType: "string", enum: ["admin", "author", "reader"], description: "must be one of: admin, author, reader" }, age: { bsonType: "int", minimum: 0, maximum: 150, description: "must be an integer between 0 and 150" } } } }, validationLevel: "strict", // "strict" | "moderate" validationAction: "error" // "error" (reject) | "warn" (log only) }) // This insert will FAIL validation (missing role, bad email) db.users.insertOne({ email: "not-an-email", password: "secret" }) // Add validation to an existing collection db.runCommand({ collMod: "posts", validator: { $jsonSchema: { bsonType: "object", required: ["title", "authorId"], properties: { title: { bsonType: "string", maxLength: 200 }, status: { enum: ["draft", "published", "archived"] } } } } })
| Limit / Rule | Value | Notes |
|---|---|---|
| Max document size | 16 MB | BSON encoded. Use GridFS for larger files. |
| Max nesting depth | 100 levels | Keep it under 5 for sanity |
| Embedded array size | No hard limit | Practical limit ~100 items for performance |
| Field name length | No hard limit | Keep short — stored with every document |
| Collections per database | No hard limit | Practical: keep collections meaningful |
| Indexes per collection | 64 max | Each index has write overhead |
fs.chunks collection with metadata in fs.files.