Overview
A relational database would almost certainly split posts and comments into two tables joined by a foreign key — that is the "referencing" approach, and it is the right call when the child data is large, unbounded, or frequently queried on its own. MongoDB's document model offers a second option that a relational schema cannot: embedding the comments directly inside the post document itself, as an array of subdocuments. This project deliberately picks embedding for comments, and the reasoning behind that choice is the actual lesson here, not the CRUD syntax around it.
Comments fit embedding well for three concrete reasons. First, a comment is never useful without its post — nobody queries "give me comment X" in isolation the way they query "give me all orders for this customer"; comments are always read together with the post they belong to, so storing them together means one `findOne` fetches everything a post page needs in a single round trip. Second, the number of comments on a single post is bounded in practice — a blog post might reasonably have dozens or hundreds of comments, but not the millions that would push a document past MongoDB's 16MB document size limit. Third, comments do not need to be queried independently across posts in this app's access patterns — there is no "show me every comment this user ever left across all posts" feature here. When any of those three conditions flips — comments needed independently, unbounded growth, or frequent updates to a comment without touching its post — referencing becomes the better choice, which is exactly the tradeoff explored from the other direction in the E-commerce Catalog & Orders project, where orders reference products instead of embedding them.
- A `posts` collection where each document embeds its comments as an array of subdocuments.
- A text index on `title` and `body` supporting full-text search across posts.
- An `insertOne` that creates a post with an initial embedded comment array.
- A `$push` update that appends a new comment to an existing post without touching the rest of the document.
- Find, update, and delete operations covering the full CRUD lifecycle for a post.
Prerequisites
- What a document and a collection are — MongoDB's equivalent of a row and a table.
- Basic CRUD — `insertOne`, `find`, `updateOne`, and `deleteOne` at a glance.
- Query operators — filtering with a plain `{ field: value }` match.
- Update operators — the idea that `updateOne` takes a filter and a separate update document.
- The general shape of BSON/JSON — nested objects and arrays as field values.
Project Structure
There is exactly one collection, `posts`. Every comment for a post lives inside that same post's document, in a `comments` array — there is no separate `comments` collection to join against, because embedding removes the need for a join entirely. Here is a representative document showing the full shape before any code touches it.
{ "_id": ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"), "title": "Why Embedding Beats Referencing for Comments", "body": "Comments are always read with their post, so keep them together...", "author": "Neha Kapoor", "tags": ["mongodb", "schema-design"], "createdAt": ISODate("2026-08-01T10:00:00Z"), "comments": [ { "commentId": ObjectId("64f1a2b3c4d5e6f7a8b9c0d2"), "author": "Arjun Rao", "text": "This finally made embedding vs referencing click for me.", "createdAt": ISODate("2026-08-01T11:15:00Z") } ]}Step 1: Insert a Post With Embedded Comments
The `comments` field starts life as a plain array of subdocuments right inside the `insertOne` call — there is no separate insert into a different collection, and no foreign key to wire up. Each comment gets its own `commentId` so a single comment can still be targeted precisely later (Step 4 and Step 5 both rely on this), even though it never leaves the parent post document.
// Insert a new blog post with one comment already embedded.db.posts.insertOne({ title: "Why Embedding Beats Referencing for Comments", // The post's headline body: "Comments are always read with their post, so keep them together...", // Full post content author: "Neha Kapoor", // Who wrote the post tags: ["mongodb", "schema-design"], // Array field — MongoDB documents don't need a separate tags table createdAt: new Date(), // When this post was published comments: [ // Embedded array: each element is a full comment subdocument, not a foreign key { commentId: new ObjectId(), // Own ID so this specific comment can be targeted later (see Step 4/5) author: "Arjun Rao", text: "This finally made embedding vs referencing click for me.", createdAt: new Date() } ]});Click Run to see what this code prints.
Step 2: Add a Text Index for Search
A regular index accelerates exact-match or range queries on a field; a **text index** is a different structure entirely, built to accelerate "does this document contain these words" queries across one or more string fields. Indexing both `title` and `body` in one `createIndex` call lets a single `$text` query search across both fields at once, weighted by relevance, instead of requiring two separate queries.
// A compound text index over both title and body — one $text query can now// search across both fields, and MongoDB scores results by relevance automatically.db.posts.createIndex({ title: "text", body: "text" });Click Run to see what this code prints.
Step 3: Find Posts by Search Term
With the text index from Step 2 in place, `$text: { $search: ... }` becomes a normal query operator instead of an expensive full-collection scan. Sorting by `{ score: { $meta: "textScore" } }` orders results by how well each document matches the search terms, most relevant first — that ranking is exactly what the text index makes possible.
// Full-text search across both title and body, most relevant match first.db.posts.find( { $text: { $search: "embedding schema" } }, // Matches documents containing either word { title: 1, score: { $meta: "textScore" } } // Project the relevance score alongside the title).sort({ score: { $meta: "textScore" } }); // Sort by relevance, not insertion order[ { "_id": "64f1a2b3c4d5e6f7a8b9c0d1", "title": "Why Embedding Beats Referencing for Comments", "score": 1.5 }]Step 4: Add a Comment With $push
`$push` appends one new element onto an array field without touching anything else in the document — the post's `title`, `body`, and every existing comment are left completely alone. This is the update that embedding was chosen for: adding a comment is a single atomic operation against a single document, with no second collection and no transaction required to keep two writes in sync.
// Append a new comment onto an existing post's embedded comments array.db.posts.updateOne( { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") }, // Filter: which post to update { $push: { comments: { // $push adds one new element to the comments array commentId: new ObjectId(), // New comment gets its own ID, same pattern as Step 1 author: "Priya Nair", text: "Great breakdown of the tradeoffs.", createdAt: new Date() } } });Click Run to see what this code prints.
Step 5: Update and Delete Operations
Editing one specific embedded comment uses the positional `$` operator combined with a filter on the array itself — `"comments.commentId": commentId` both locates the right post and tells MongoDB which array element `comments.$.text` refers to. Removing a comment uses `$pull` to strip a matching element out of the array in place; deleting the whole post is an ordinary `deleteOne`, which removes every embedded comment along with it automatically, since they were never a separate collection to begin with.
// Edit one specific embedded comment's text, located by its own commentId.db.posts.updateOne( { "comments.commentId": ObjectId("64f1a2b3c4d5e6f7a8b9c0d2") }, // Find the post containing this comment { $set: { "comments.$.text": "Edited: great breakdown of the tradeoffs!" } } // $ = the matched array element);
// Remove one specific comment from a post's embedded array.db.posts.updateOne( { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") }, { $pull: { comments: { commentId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d2") } } } // $pull strips the matching element out);
// Delete an entire post — its embedded comments go with it, no separate cleanup needed.db.posts.deleteOne({ _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") });Deleting a post in a referenced design would require a second query to delete its orphaned comments (or a database trigger to do it for you). Embedding makes that entire cleanup step disappear — one `deleteOne` against `posts` is already complete.
Complete Code
The full set of operations from the steps above, assembled in the order you would actually run them against a live database.
// 1. Create the text index once, up frontdb.posts.createIndex({ title: "text", body: "text" });
// 2. Insert a post with an embedded commentdb.posts.insertOne({ title: "Why Embedding Beats Referencing for Comments", body: "Comments are always read with their post, so keep them together...", author: "Neha Kapoor", tags: ["mongodb", "schema-design"], createdAt: new Date(), comments: [ { commentId: new ObjectId(), author: "Arjun Rao", text: "This finally made embedding vs referencing click for me.", createdAt: new Date() } ]});
// 3. Search posts by textdb.posts.find( { $text: { $search: "embedding schema" } }, { title: 1, score: { $meta: "textScore" } }).sort({ score: { $meta: "textScore" } });
// 4. Add a new commentdb.posts.updateOne( { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") }, { $push: { comments: { commentId: new ObjectId(), author: "Priya Nair", text: "Great breakdown of the tradeoffs.", createdAt: new Date() } } });
// 5. Edit, remove, or deletedb.posts.updateOne({ "comments.commentId": ObjectId("64f1a2b3c4d5e6f7a8b9c0d2") }, { $set: { "comments.$.text": "Edited text" } });db.posts.updateOne({ _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") }, { $pull: { comments: { commentId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d2") } } });db.posts.deleteOne({ _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") });Sample Queries
Three more operations a real blog would run constantly: counting comments per post, finding posts by tag, and paging through recent posts.
// How many comments does each post have, without a separate count query per postdb.posts.aggregate([ { $project: { title: 1, commentCount: { $size: "$comments" } } }, // $size counts the embedded array in place { $sort: { commentCount: -1 } }]);| title | commentCount |
|---|---|
| Why Embedding Beats Referencing for Comments | 2 |
// Posts tagged 'schema-design', newest firstdb.posts.find({ tags: "schema-design" }).sort({ createdAt: -1 }); // Matching an array field against a single value checks membership[ { "_id": "64f1a2b3c4d5e6f7a8b9c0d1", "title": "Why Embedding Beats Referencing for Comments", "tags": ["mongodb", "schema-design"] }]Extend This Project
- Add a `likes` counter per comment and a `$inc` update to increment it atomically.
- Add pagination with `.skip()`/`.limit()` (or a range query on `_id`) for blogs with many posts.
- Cap embedded comments at, say, 200 and move older ones to an archive collection if a post goes viral.
- Add a compound index on `{ author: 1, createdAt: -1 }` to speed up an author's post history page.
- Add nested replies by embedding a `replies` array inside each comment subdocument.
Summary
You modeled a one-to-many relationship by embedding comments directly inside their post, because comments here are always read with their post, bounded in number, and never queried independently — the exact conditions that make embedding the right call in MongoDB's document model. `$push`, the positional `$` operator, and `$pull` let you add, edit, and remove individual embedded comments without ever needing a join, and the text index turned full-text search into an ordinary indexed query instead of a full collection scan.