MongoDB Aggregation
Pipeline
You already know find, insert, update. Today you learn the framework that lets the database do the work your JS loops have been doing. Every stage explained with a reason to want it first — never syntax without motivation.
The dataset. Four collections — users, posts, comments, follows. These exact 45 documents appear in every example below. Every _id referenced later (u1–u8, p1–p12, c1–c15, f1–f10) maps to what you see here. Nothing is generated or altered.
[
{ _id: "u1", name: "Aarav Sharma", username: "aarav_codes", email: "aarav@mail.com", age: 22, city: "Bhopal", joinedAt: "2024-01-15", isVerified: true, followersCount: 1200 },
{ _id: "u2", name: "Diya Patel", username: "diya.travels", email: "diya@mail.com", age: 25, city: "Indore", joinedAt: "2024-02-20", isVerified: true, followersCount: 4500 },
{ _id: "u3", name: "Kabir Singh", username: "kabir_fit", email: "kabir@mail.com", age: 28, city: "Bhopal", joinedAt: "2023-11-05", isVerified: false, followersCount: 320 },
{ _id: "u4", name: "Ananya Rao", username: "ananya.eats", email: "ananya@mail.com", age: 24, city: "Indore", joinedAt: "2024-03-10", isVerified: true, followersCount: 8900 },
{ _id: "u5", name: "Vihaan Gupta", username: "vihaan_art", email: "vihaan@mail.com", age: 21, city: "Pune", joinedAt: "2024-05-01", isVerified: false, followersCount: 150 },
{ _id: "u6", name: "Ishita Verma", username: "ishita.codes", email: "ishita@mail.com", age: 23, city: "Bhopal", joinedAt: "2024-01-28", isVerified: true, followersCount: 2100 },
{ _id: "u7", name: "Reyansh Joshi", username: "rey_travels", email: "reyansh@mail.com", age: 26, city: "Pune", joinedAt: "2023-12-12", isVerified: false, followersCount: 600 },
{ _id: "u8", name: "Myra Kapoor", username: "myra.fitlife", email: "myra@mail.com", age: 27, city: "Indore", joinedAt: "2024-04-18", isVerified: true, followersCount: 3300 }
]
[
{ _id: "p1", userId: "u1", caption: "Deployed my first MERN app today!", tags: ["mern","webdev","nodejs"], likesCount: 340, commentsCount: 3, createdAt: "2024-06-01", category: "tech" },
{ _id: "p2", userId: "u2", caption: "Sunset in Manali, worth every step", tags: ["travel","himalayas","sunset"], likesCount: 890, commentsCount: 2, createdAt: "2024-06-03", category: "travel" },
{ _id: "p3", userId: "u4", caption: "Best butter chicken in Indore, change my mind", tags: ["food","indore","foodie"], likesCount: 1200, commentsCount: 4, createdAt: "2024-06-05", category: "food" },
{ _id: "p4", userId: "u3", caption: "Leg day never lies", tags: ["fitness","gym","legday"], likesCount: 210, commentsCount: 1, createdAt: "2024-06-06", category: "fitness" },
{ _id: "p5", userId: "u5", caption: "New charcoal sketch, took 6 hours", tags: ["art","sketch","charcoal"], likesCount: 430, commentsCount: 2, createdAt: "2024-06-07", category: "art" },
{ _id: "p6", userId: "u6", caption: "Understanding closures finally clicked", tags: ["javascript","webdev"], likesCount: 560, commentsCount: 3, createdAt: "2024-06-08", category: "tech" },
{ _id: "p7", userId: "u2", caption: "Road trip through Spiti valley", tags: ["travel","spiti","roadtrip"], likesCount: 1450, commentsCount: 0, createdAt: "2024-06-10", category: "travel" },
{ _id: "p8", userId: "u8", caption: "5AM workout hits different", tags: ["fitness","motivation"], likesCount: 780, commentsCount: 2, createdAt: "2024-06-11", category: "fitness" },
{ _id: "p9", userId: "u4", caption: "Street food tour of Indore, thread", tags: ["food","indore","streetfood"], likesCount: 2100, commentsCount: 0, createdAt: "2024-06-12", category: "food" },
{ _id: "p10", userId: "u1", caption: "MongoDB aggregation is actually beautiful", tags: ["mongodb","webdev","backend"], likesCount: 670, commentsCount: 3, createdAt: "2024-06-14", category: "tech" },
{ _id: "p11", userId: "u7", caption: "Backwaters of Kerala at dawn", tags: ["travel","kerala"], likesCount: 950, commentsCount: 0, createdAt: "2024-06-15", category: "travel" },
{ _id: "p12", userId: "u5", caption: "Digital art vs traditional, my take", tags: ["art","digitalart"], likesCount: 310, commentsCount: 0, createdAt: "2024-06-16", category: "art" }
]
[
{ _id: "c1", postId: "p1", userId: "u6", text: "This is so clean, great work!", createdAt: "2024-06-01", likesCount: 12 },
{ _id: "c2", postId: "p1", userId: "u3", text: "What stack did you use?", createdAt: "2024-06-02", likesCount: 4 },
{ _id: "c3", postId: "p1", userId: "u2", text: "Congrats on shipping it", createdAt: "2024-06-02", likesCount: 8 },
{ _id: "c4", postId: "p2", userId: "u1", text: "Manali is on my list now", createdAt: "2024-06-03", likesCount: 15 },
{ _id: "c5", postId: "p2", userId: "u4", text: "That sky though", createdAt: "2024-06-04", likesCount: 22 },
{ _id: "c6", postId: "p3", userId: "u1", text: "Ok now I'm hungry", createdAt: "2024-06-05", likesCount: 9 },
{ _id: "c7", postId: "p3", userId: "u8", text: "Which place exactly?", createdAt: "2024-06-05", likesCount: 6 },
{ _id: "c8", postId: "p3", userId: "u6", text: "Facts, best in the city", createdAt: "2024-06-06", likesCount: 11 },
{ _id: "c9", postId: "p3", userId: "u3", text: "Adding to my list", createdAt: "2024-06-06", likesCount: 3 },
{ _id: "c10", postId: "p4", userId: "u8", text: "Respect the grind", createdAt: "2024-06-06", likesCount: 18 },
{ _id: "c11", postId: "p5", userId: "u1", text: "The shading is insane", createdAt: "2024-06-07", likesCount: 25 },
{ _id: "c12", postId: "p5", userId: "u7", text: "How long did this take?", createdAt: "2024-06-08", likesCount: 5 },
{ _id: "c13", postId: "p6", userId: "u1", text: "Closures took me a month lol", createdAt: "2024-06-08", likesCount: 30 },
{ _id: "c14", postId: "p6", userId: "u5", text: "Underrated concept, good post", createdAt: "2024-06-09", likesCount: 14 },
{ _id: "c15", postId: "p6", userId: "u8", text: "Bookmarking this", createdAt: "2024-06-09", likesCount: 7 }
]
[
{ _id: "f1", followerId: "u1", followingId: "u2", createdAt: "2024-02-01" },
{ _id: "f2", followerId: "u1", followingId: "u4", createdAt: "2024-03-15" },
{ _id: "f3", followerId: "u3", followingId: "u8", createdAt: "2024-04-20" },
{ _id: "f4", followerId: "u5", followingId: "u2", createdAt: "2024-05-05" },
{ _id: "f5", followerId: "u6", followingId: "u1", createdAt: "2024-02-10" },
{ _id: "f6", followerId: "u7", followingId: "u2", createdAt: "2024-01-01" },
{ _id: "f7", followerId: "u7", followingId: "u4", createdAt: "2024-03-01" },
{ _id: "f8", followerId: "u8", followingId: "u4", createdAt: "2024-04-25" },
{ _id: "f9", followerId: "u2", followingId: "u4", createdAt: "2024-02-28" },
{ _id: "f10", followerId: "u4", followingId: "u2", createdAt: "2024-03-05" }
]
Here's a real question on this dataset: which city has the most verified users, and what's the average follower count of those verified users in that city?
You know CRUD. So you solve it the only way you know — fetch everything, loop in JavaScript, and hope the dataset stays small.
The Painful Way
// Step 1: Fetch ALL 8 users from the database const allUsers = await db.collection('users').find({}).toArray(); // Step 2: Filter verified users in JS — 3 docs pulled for nothing const verified = allUsers.filter(u => u.isVerified); // → [u1, u2, u4, u6, u8] — 5 docs kept, 3 wasted bandwidth // Step 3: Group by city manually const byCity = {}; for (const u of verified) { if (!byCity[u.city]) byCity[u.city] = []; byCity[u.city].push(u); } // → { Bhopal: [u1, u6], Indore: [u2, u4, u8] } // Step 4: Compute count + average per city const results = Object.entries(byCity).map(([city, users]) => ({ city, verifiedCount: users.length, avgFollowers: users.reduce((sum, u) => sum + u.followersCount, 0) / users.length })); // → [ // { city: "Bhopal", verifiedCount: 2, avgFollowers: 1650 }, // { city: "Indore", verifiedCount: 3, avgFollowers: 5566.67 } // ] // Step 5: Sort by count descending results.sort((a, b) => b.verifiedCount - a.verifiedCount); // Step 6: Grab the winner console.log(results[0]); // → { city: "Indore", verifiedCount: 3, avgFollowers: 5566.67 }
Six steps. 20+ lines. Every document fetched over the wire. If users grows to 10 million, you're pulling 10 million documents to filter 3 million away. The database ships data you throw in the trash.
The Aggregation Way
Same result. Four stages. The database does all the work — only the final row crosses the network.
db.users.aggregate([ { $match: { isVerified: true } }, { $group: { _id: "$city", verifiedCount: { $sum: 1 }, avgFollowers: { $avg: "$followersCount" } }}, { $sort: { verifiedCount: -1 } }, { $limit: 1 } ])
[
{ _id: "Indore", verifiedCount: 3, avgFollowers: 5566.666666666667 }
]
One document returned. The filtering, grouping, averaging, sorting — all happened inside MongoDB. The aggregation framework is a pipeline: you feed it stages, documents flow through each one, and what comes out the other end is your answer.
A pipeline is an array. Each element is a stage — a single operation like filter, group, or sort. Documents enter the first stage, get transformed, and the entire output of that stage becomes the input to the next. If stage 1 outputs 5 documents, stage 2 receives exactly 5 documents. No more, no less — unless a stage itself changes the count (like $group collapsing many into few, or $unwind splitting one into many).
Order matters. $match before $group means fewer documents to group. $limit after $sort means you get the true top N. Swap them and you get garbage. Think of it like a Unix pipe — each stage feeds the next.
Without it: you pull every document from the collection and discard the ones you don't need in JS. With 8 users that's fine. With 8 million, your app crashes before the loop finishes.
What it does: passes through only documents that match your criteria. Identical syntax to find() query operators. Put it first whenever possible — MongoDB can use indexes on $match, and earlier filtering means every subsequent stage processes fewer documents.
Example 1 — Verified users only
db.users.aggregate([ { $match: { isVerified: true } } ])
[
{ _id: "u1", name: "Aarav Sharma", ... isVerified: true, followersCount: 1200 },
{ _id: "u2", name: "Diya Patel", ... isVerified: true, followersCount: 4500 },
{ _id: "u4", name: "Ananya Rao", ... isVerified: true, followersCount: 8900 },
{ _id: "u6", name: "Ishita Verma", ... isVerified: true, followersCount: 2100 },
{ _id: "u8", name: "Myra Kapoor", ... isVerified: true, followersCount: 3300 }
]
Example 2 — Food or tech posts with 800+ likes
db.posts.aggregate([ { $match: { likesCount: { $gt: 800 }, category: { $in: ["tech", "food"] } }} ])
[
{ _id: "p3", userId: "u4", caption: "Best butter chicken in Indore, change my mind", likesCount: 1200, category: "food", ... },
{ _id: "p9", userId: "u4", caption: "Street food tour of Indore, thread", likesCount: 2100, category: "food", ... }
]
Always put $match as early as possible. MongoDB can use indexes to satisfy a $match at the start of a pipeline. Once you run a $project or $group before $match, the optimizer can't use indexes — it has to scan every document. This is the #1 performance mistake in aggregation.
Without it: you fetch all posts, loop through them in JS, maintain a dictionary keyed by category, and compute sums/averages yourself. It works, but the database has to ship every document to your app, and your JS is single-threaded.
What it does: groups input documents by a specified key (_id) and applies accumulator operators to each group. The output is one document per unique group value.
Example 1 — Count posts per category
db.posts.aggregate([ { $group: { _id: "$category", postCount: { $sum: 1 } }} ])
[
{ _id: "tech", postCount: 3 },
{ _id: "travel", postCount: 3 },
{ _id: "food", postCount: 2 },
{ _id: "fitness", postCount: 2 },
{ _id: "art", postCount: 2 }
]
Example 2 — Total + average likes per category
db.posts.aggregate([ { $group: { _id: "$category", totalLikes: { $sum: "$likesCount" }, avgLikes: { $avg: "$likesCount" }, postCount: { $sum: 1 } }}, { $sort: { totalLikes: -1 } } ])
[
{ _id: "food", totalLikes: 3300, avgLikes: 1650, postCount: 2 },
{ _id: "travel", totalLikes: 3290, avgLikes: 1096.67, postCount: 3 },
{ _id: "tech", totalLikes: 1570, avgLikes: 523.33, postCount: 3 },
{ _id: "fitness", totalLikes: 990, avgLikes: 495, postCount: 2 },
{ _id: "art", totalLikes: 740, avgLikes: 370, postCount: 2 }
]
$group does not preserve document order. The output order is arbitrary unless you add a $sort after. Also, grouping by null (_id: null) collapses all documents into a single group — useful for counting an entire collection.
Without it: every document ships over the wire with all 9 fields, even if your frontend only needs 2. You're paying bandwidth and serialization costs for data you throw away.
What it does: passes through documents with only the fields you specify. Use 1 to include, 0 to exclude. You can also compute new fields — expressions make $project the stage where data gets reshaped, not just trimmed.
Example 1 — Pick only name and username
db.users.aggregate([ { $project: { _id: 0, name: 1, username: 1 } } ])
[
{ name: "Aarav Sharma", username: "aarav_codes" },
{ name: "Diya Patel", username: "diya.travels" },
{ name: "Kabir Singh", username: "kabir_fit" },
{ name: "Ananya Rao", username: "ananya.eats" },
{ name: "Vihaan Gupta", username: "vihaan_art" },
{ name: "Ishita Verma", username: "ishita.codes" },
{ name: "Reyansh Joshi", username: "rey_travels" },
{ name: "Myra Kapoor", username: "myra.fitlife" }
]
Example 2 — Compute engagement (likes + comments)
db.posts.aggregate([ { $project: { _id: 1, caption: 1, engagement: { $add: ["$likesCount", "$commentsCount"] } }} ])
[
{ _id: "p1", caption: "Deployed my first MERN app today!", engagement: 343 },
{ _id: "p2", caption: "Sunset in Manali, worth every step", engagement: 892 },
{ _id: "p3", caption: "Best butter chicken in Indore...", engagement: 1204 },
{ _id: "p4", caption: "Leg day never lies", engagement: 211 },
{ _id: "p5", caption: "New charcoal sketch, took 6 hours", engagement: 432 },
{ _id: "p6", caption: "Understanding closures finally clicked", engagement: 563 },
{ _id: "p7", caption: "Road trip through Spiti valley", engagement: 1450 },
{ _id: "p8", caption: "5AM workout hits different", engagement: 782 },
{ _id: "p9", caption: "Street food tour of Indore, thread", engagement: 2100 },
{ _id: "p10", caption: "MongoDB aggregation is actually beautiful", engagement: 673 },
{ _id: "p11", caption: "Backwaters of Kerala at dawn", engagement: 950 },
{ _id: "p12", caption: "Digital art vs traditional, my take", engagement: 310 }
]
You cannot mix 1 and 0 freely. Once you include a field with 1, all other fields are excluded by default — you can only use 0 to explicitly remove _id. The exception: if you set only exclusions (all 0), the rest come along. This is the most common $project mistake.
Without it: you fetch all documents, then call .sort() in JS on the full array. Fine for 12 posts, catastrophic for 12 million. The database has an internal sort that's dramatically faster.
What it does: reorders all incoming documents. Use 1 for ascending, -1 for descending. Multiple fields are sorted lexicographically — first field first, ties broken by second, etc.
Example 1 — Posts by likes descending
db.posts.aggregate([ { $sort: { likesCount: -1 } } ])
[
{ _id: "p9", likesCount: 2100, caption: "Street food tour of Indore, thread", ... },
{ _id: "p7", likesCount: 1450, caption: "Road trip through Spiti valley", ... },
{ _id: "p3", likesCount: 1200, caption: "Best butter chicken in Indore...", ... },
{ _id: "p11", likesCount: 950, caption: "Backwaters of Kerala at dawn", ... },
{ _id: "p2", likesCount: 890, caption: "Sunset in Manali, worth every step", ... }
]
Example 2 — Users by followers ascending, then age descending
db.users.aggregate([ { $sort: { followersCount: 1, age: -1 } }, { $project: { name: 1, followersCount: 1, age: 1, _id: 0 } } ])
[
{ name: "Vihaan Gupta", followersCount: 150, age: 21 },
{ name: "Kabir Singh", followersCount: 320, age: 28 },
{ name: "Reyansh Joshi", followersCount: 600, age: 26 },
{ name: "Aarav Sharma", followersCount: 1200, age: 22 },
{ name: "Ishita Verma", followersCount: 2100, age: 23 },
{ name: "Myra Kapoor", followersCount: 3300, age: 27 },
{ name: "Diya Patel", followersCount: 4500, age: 25 },
{ name: "Ananya Rao", followersCount: 8900, age: 24 }
]
$sort has a 100MB memory limit by default. If the pipeline is sorting more data than fits in memory, MongoDB throws an error. Add { allowDiskUse: true } as a second argument to aggregate() to let it spill to disk. Better: always $match before $sort to reduce the input size.
Without it: you fetch all 12 posts to display a "Top 3" leaderboard. 9 documents crossed the network for nothing.
What it does: passes through at most N documents, drops the rest. Pair with $sort to get "top N" — but $sort must come before $limit, or you're limiting random documents then sorting what's left.
Example — Top 3 most liked posts
db.posts.aggregate([ { $sort: { likesCount: -1 } }, { $limit: 3 }, { $project: { caption: 1, likesCount: 1, _id: 0 } } ])
[
{ caption: "Street food tour of Indore, thread", likesCount: 2100 },
{ caption: "Road trip through Spiti valley", likesCount: 1450 },
{ caption: "Best butter chicken in Indore...", likesCount: 1200 }
]
Without it: you implement pagination by fetching all documents and slicing the array in JS. Page 1000 means you fetched 10,000 documents to show 10.
What it does: drops the first N documents, passes everything after. Almost always paired with $limit for pagination: $skip N, then $limit pageSize.
Example — Pagination: page 2 of posts (3 per page, sorted by likes)
db.posts.aggregate([ { $sort: { likesCount: -1 } }, { $skip: 3 }, // skip page 1 (3 items) { $limit: 3 }, // take page 2 { $project: { caption: 1, likesCount: 1, _id: 0 } } ])
[
{ caption: "Backwaters of Kerala at dawn", likesCount: 950 },
{ caption: "Sunset in Manali, worth every step", likesCount: 890 },
{ caption: "5AM workout hits different", likesCount: 780 }
]
$skip then $limit, not the reverse. If you $limit first, you keep only the first 3, then skip 3 of those — you get zero. The order is: sort → skip → limit.
Without it: you want to count how many posts use the tag "webdev". The tags live inside an array on each post. With find(), you'd loop through every post, then loop through every tag, maintaining a tally in JS.
What it does: takes one document with an array of N elements and produces N documents, each with one element from the array. The original array field becomes a scalar. This is the prerequisite for $group on array contents.
Example 1 — Unwind tags on posts
db.posts.aggregate([ { $unwind: "$tags" }, { $project: { _id: 1, caption: 1, tags: 1 } } ])
// p1 has 3 tags → 3 documents: { _id: "p1", caption: "Deployed my first MERN app today!", tags: "mern" }, { _id: "p1", caption: "Deployed my first MERN app today!", tags: "webdev" }, { _id: "p1", caption: "Deployed my first MERN app today!", tags: "nodejs" }, // p6 has 2 tags → 2 documents: { _id: "p6", caption: "Understanding closures finally clicked", tags: "javascript" }, { _id: "p6", caption: "Understanding closures finally clicked", tags: "webdev" }, // ... 32 total documents, one per tag per post
Example 2 — Most popular tags (unwind → group → sort)
db.posts.aggregate([ { $unwind: "$tags" }, { $group: { _id: "$tags", count: { $sum: 1 } } }, { $sort: { count: -1 } } ])
[
{ _id: "webdev", count: 3 }, // p1, p6, p10
{ _id: "travel", count: 3 }, // p2, p7, p11
{ _id: "food", count: 2 }, // p3, p9
{ _id: "indore", count: 2 }, // p3, p9
{ _id: "fitness", count: 2 }, // p4, p8
{ _id: "art", count: 2 }, // p5, p12
// ... 18 more tags with count: 1
]
Forgetting $unwind before $group on array contents is the #1 aggregation bug. If you $group on $tags without unwinding first, MongoDB treats the entire array as a single key — you'd get one group per unique array combination, not per tag.
Without it: to show a post with its author's name, you fetch the post, extract userId, fire a second query to users, and stitch them together in JS. For a feed of 50 posts, that's 51 round trips to the database.
What it does: performs a left outer join from the current collection to another collection. For each input document, it finds matching documents in the foreign collection and attaches them as an array.
Example 1 — Attach author info to each post
db.posts.aggregate([ { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "author" }}, { $project: { caption: 1, "author.name": 1, "author.city": 1, _id: 0 } } ])
[
{ caption: "Deployed my first MERN app today!", author: [{ name: "Aarav Sharma", city: "Bhopal" }] },
{ caption: "Sunset in Manali, worth every step", author: [{ name: "Diya Patel", city: "Indore" }] },
{ caption: "Best butter chicken in Indore...", author: [{ name: "Ananya Rao", city: "Indore" }] },
// ... 9 more
]
Example 2 — Each user with their post count
db.users.aggregate([ { $lookup: { from: "posts", localField: "_id", foreignField: "userId", as: "userPosts" }}, { $project: { name: 1, postCount: { $size: "$userPosts" }, _id: 0 }} ])
[
{ name: "Aarav Sharma", postCount: 2 }, // p1, p10
{ name: "Diya Patel", postCount: 2 }, // p2, p7
{ name: "Kabir Singh", postCount: 1 }, // p4
{ name: "Ananya Rao", postCount: 2 }, // p3, p9
{ name: "Vihaan Gupta", postCount: 2 }, // p5, p12
{ name: "Ishita Verma", postCount: 1 }, // p6
{ name: "Reyansh Joshi", postCount: 1 }, // p11
{ name: "Myra Kapoor", postCount: 1 } // p8
]
The joined field is always an array, even if only one document matches. Use $unwind on the as field if you want to flatten it back to a single object. Create an index on the foreignField (userId on posts) — without it, every $lookup does a full collection scan.
Without it: to find "everyone in u2's follower network" (who follows u2, and who follows those followers, and so on), you'd write a recursive function that fires one query per level. For a social graph, that's potentially hundreds of round trips.
What it does: recursively traverses a collection, following a connect-from → connect-to relationship. Starting from a seed value, it finds all reachable documents. Perfect for org charts, category trees, and social graphs.
Example — Find u2's recursive follower network
Starting from u2, find everyone who follows u2, then everyone who follows those people, and so on — traversing the follows collection where followingId points to the current user and followerId is the next hop.
db.users.aggregate([ { $match: { _id: "u2" } }, { $graphLookup: { from: "follows", startWith: "$_id", // seed = "u2" connectFromField: "followerId", // hop to next level via followerId connectToField: "followingId", // match against followingId as: "followerNetwork" }}, { $project: { name: 1, networkSize: { $size: "$followerNetwork" }, _id: 0 } } ])
[
{
name: "Diya Patel",
networkSize: 4,
// followerNetwork contains 4 follow edges:
// f1: u1 → u2 (u1 directly follows u2)
// f4: u5 → u2 (u5 directly follows u2)
// f6: u7 → u2 (u7 directly follows u2)
// f5: u6 → u1 (u6 follows u1, who follows u2)
}
]
Level 0: start with "u2". Find follows where followingId = "u2" → f1 (u1), f4 (u5), f6 (u7). Three direct followers found.
Level 1: take followerId values {u1, u5, u7}. Find follows where followingId ∈ {u1, u5, u7} → f5 (u6 → u1). One new follower: u6.
Level 2: take {u6}. Find follows where followingId = "u6" → none. Traversal ends.
Total: 4 edges in the network. The people who (transitively) follow u2: u1, u5, u7, u6.
$graphLookup is expensive. It's a recursive traversal with no index on the connectToField beyond a basic scan. For deep graphs, add maxDepth to cap recursion. For shallow (1-level) joins, use regular $lookup — it's faster.
Without it: you fetch documents, loop through them in JS to add computed fields, then use the modified array. The transformation happens outside the database.
What it does: adds new fields (or overwrites existing ones) to every document. Unlike $project, all original fields are preserved — you're only adding. $set is an alias for $addFields (identical behavior, different name).
Example 1 — Add engagement score to posts
db.posts.aggregate([ { $addFields: { engagement: { $add: ["$likesCount", "$commentsCount"] } }}, { $project: { caption: 1, engagement: 1, _id: 0 } } ])
[
{ caption: "Deployed my first MERN app today!", engagement: 343 },
{ caption: "Sunset in Manali, worth every step", engagement: 892 },
{ caption: "Best butter chicken in Indore...", engagement: 1204 },
{ caption: "Leg day never lies", engagement: 211 }
]
Example 2 — Add follower tier to users
db.users.aggregate([ { $addFields: { tier: { $switch: { branches: [ { case: { $gte: ["$followersCount", 5000] }, then: "mega" }, { case: { $gte: ["$followersCount", 1000] }, then: "mid" } ], default: "small" } } }}, { $project: { name: 1, followersCount: 1, tier: 1, _id: 0 } } ])
[
{ name: "Aarav Sharma", followersCount: 1200, tier: "mid" },
{ name: "Diya Patel", followersCount: 4500, tier: "mid" },
{ name: "Kabir Singh", followersCount: 320, tier: "small" },
{ name: "Ananya Rao", followersCount: 8900, tier: "mega" },
{ name: "Vihaan Gupta", followersCount: 150, tier: "small" },
{ name: "Ishita Verma", followersCount: 2100, tier: "mid" },
{ name: "Reyansh Joshi", followersCount: 600, tier: "small" },
{ name: "Myra Kapoor", followersCount: 3300, tier: "mid" }
]
Without it: you use $project with exclusion mode, but $project can't also compute new fields in the same stage. You'd need two stages. $unset is the clean way to drop fields while keeping everything else.
What it does: removes specified fields from every document. Accepts a single field name string or an array of field names. The opposite of $addFields.
Example — Strip email and isVerified from users
db.users.aggregate([ { $unset: ["email", "isVerified"] } ])
{
_id: "u1",
name: "Aarav Sharma",
username: "aarav_codes",
age: 22,
city: "Bhopal",
joinedAt: "2024-01-15",
followersCount: 1200
// email and isVerified are gone, everything else preserved
}
Use $unset when you want to drop fields but keep everything else. Use $project with inclusion mode when you want to keep only specific fields. They're inverse operations with different ergonomics.
Without it: after a $lookup, the joined data sits in an array field. You want the joined data to be the document, not a nested array. $project can rename fields one by one, but can't wholesale replace the document root.
What it does: discards the current document and replaces it entirely with the specified object. $replaceWith is the simpler alias (MongoDB 4.2+) — it takes a single expression that becomes the new document. $replaceRoot is the older form using { newRoot: ... }.
Example — Reshape posts into a minimal view
db.posts.aggregate([ { $replaceWith: { id: "$_id", text: "$caption", popularity: "$likesCount", topic: "$category" }} ])
[
{ id: "p1", text: "Deployed my first MERN app today!", popularity: 340, topic: "tech" },
{ id: "p2", text: "Sunset in Manali, worth every step", popularity: 890, topic: "travel" },
{ id: "p3", text: "Best butter chicken in Indore...", popularity: 1200, topic: "food" }
]
A common pattern: $replaceRoot with $mergeObjects to flatten a looked-up sub-document into the top level:
db.posts.aggregate([ { $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "author" }}, { $unwind: "$author" }, { $replaceRoot: { newRoot: { $mergeObjects: ["$author", "$$ROOT"] } }}, { $unset: ["author", "userId"] } ]) // Now each post document has author fields (name, city, etc.) // merged in at the top level, no nesting.
Without it: you run an aggregation, get results in your app, then loop through them calling insertMany() to save to another collection. Two round trips and a lot of JS.
What it does: writes the output of the pipeline to a collection. If the collection exists, it's dropped and replaced entirely. Must be the last stage in the pipeline.
Example — Save category stats to a new collection
db.posts.aggregate([ { $group: { _id: "$category", totalLikes: { $sum: "$likesCount" }, avgLikes: { $avg: "$likesCount" }, postCount: { $sum: 1 } }}, { $out: "categoryStats" } ])
[
{ _id: "tech", totalLikes: 1570, avgLikes: 523.33, postCount: 3 },
{ _id: "travel", totalLikes: 3290, avgLikes: 1096.67, postCount: 3 },
{ _id: "food", totalLikes: 3300, avgLikes: 1650, postCount: 2 },
{ _id: "fitness", totalLikes: 990, avgLikes: 495, postCount: 2 },
{ _id: "art", totalLikes: 740, avgLikes: 370, postCount: 2 }
]
$out destroys the target collection if it exists. All existing documents are gone — this is not a merge. If you need to add to or update an existing collection, use $merge instead. Also, $out can only write to the same database, and the output collection cannot be sharded.
Without it: you compute daily stats, fetch existing documents from the stats collection, compare, update or insert each one individually. A cron job becomes 50 database calls.
What it does: writes pipeline output to a collection with fine-grained control: insert new, replace existing, merge fields, or keep existing. Unlike $out, it preserves existing documents. Can target a different database.
Example — Merge user count per city into cityStats, replacing on match
db.users.aggregate([ { $group: { _id: "$city", userCount: { $sum: 1 }, avgFollowers: { $avg: "$followersCount" } }}, { $merge: { into: "cityStats", on: "_id", whenMatched: "replace", whenNotMatched: "insert" }} ])
[
{ _id: "Bhopal", userCount: 3, avgFollowers: 1206.67 },
{ _id: "Indore", userCount: 3, avgFollowers: 5566.67 },
{ _id: "Pune", userCount: 2, avgFollowers: 375 }
]
Use $merge for incremental updates, $out for full rebuilds. If you re-run a daily aggregation, $merge with whenMatched: "replace" updates today's row without touching yesterday's. $out would wipe the entire collection.
Without it: you call .length on the result array in JS. That means every document was fetched over the network just to be counted. You shipped data to count it.
What it does: counts the number of documents at this point in the pipeline and returns a single document with the count in the field name you specify. The pipeline ends here — there's nothing left to count after.
Example 1 — Count verified users
db.users.aggregate([ { $match: { isVerified: true } }, { $count: "verifiedCount" } ])
[ { verifiedCount: 5 } ]
Example 2 — Count posts with 500+ likes
db.posts.aggregate([ { $match: { likesCount: { $gt: 500 } } }, { $count: "popularPosts" } ])
[ { popularPosts: 8 } ]
Without it: you fetch all documents, shuffle the array in JS with Fisher-Yates, and slice. Every document crosses the network so you can throw most of them away.
What it does: randomly selects up to N documents from the current pipeline input. The selection is pseudo-random — MongoDB uses a sampling algorithm internally. Output order is not guaranteed.
Example — Pick 3 random posts
db.posts.aggregate([ { $sample: { size: 3 } }, { $project: { caption: 1, _id: 0 } } ])
[
{ caption: "5AM workout hits different" },
{ caption: "New charcoal sketch, took 6 hours" },
{ caption: "MongoDB aggregation is actually beautiful" }
]
// Your output WILL differ — these are random picks.
$sample is great for "featured post" carousels or A/B test bucketing. If the pipeline input is small (like our 12 posts), MongoDB may scan all of them. For large collections, it uses a more efficient sampling strategy.
Without it: your dashboard needs (a) posts grouped by category, (b) top 3 liked posts, and (c) total post count. You run three separate aggregation queries. Three round trips, and the data might change between queries.
What it does: takes the current pipeline input and fans it out into multiple named sub-pipelines. Each sub-pipeline runs independently on a copy of the same data. The output is a single document with one field per sub-pipeline, each containing that pipeline's results.
Example — Dashboard: category counts + top posts + total count in one query
db.posts.aggregate([ { $facet: { "byCategory": [ { $group: { _id: "$category", count: { $sum: 1 } } }, { $sort: { count: -1 } } ], "topLiked": [ { $sort: { likesCount: -1 } }, { $limit: 3 }, { $project: { caption: 1, likesCount: 1, _id: 0 } } ], "totalCount": [ { $count: "count" } ] }} ])
[
{
byCategory: [
{ _id: "tech", count: 3 },
{ _id: "travel", count: 3 },
{ _id: "food", count: 2 },
{ _id: "fitness", count: 2 },
{ _id: "art", count: 2 }
],
topLiked: [
{ caption: "Street food tour of Indore, thread", likesCount: 2100 },
{ caption: "Road trip through Spiti valley", likesCount: 1450 },
{ caption: "Best butter chicken in Indore...", likesCount: 1200 }
],
totalCount: [ { count: 12 } ]
}
]
Each sub-pipeline is independent — they don't see each other's results. The input to each sub-pipeline is the documents that entered $facet. Put $match before $facet so all sub-pipelines operate on the same filtered set.
Without it: you fetch all users, then write if/else chains in JS to categorize each one into age buckets (20-22, 23-25, 26-29), maintaining count arrays per bucket.
What it does: categorizes documents into groups based on a specified field's value, using explicit boundary values you provide. Each bucket is [lower, upper) — lower inclusive, upper exclusive — except the last, which is inclusive on both ends.
Example — Bucket users by age
db.users.aggregate([ { $bucket: { groupBy: "$age", boundaries: [20, 23, 26, 30], default: "30+", output: { count: { $sum: 1 }, names: { $push: "$name" } } }} ])
[
{
_id: 20,
count: 2,
names: [ "Aarav Sharma", "Vihaan Gupta" ]
// u1 (age 22), u5 (age 21) → bucket [20, 23)
},
{
_id: 23,
count: 3,
names: [ "Ishita Verma", "Ananya Rao", "Diya Patel" ]
// u6 (age 23), u4 (age 24), u2 (age 25) → bucket [23, 26)
},
{
_id: 26,
count: 3,
names: [ "Reyansh Joshi", "Myra Kapoor", "Kabir Singh" ]
// u7 (age 26), u8 (age 27), u3 (age 28) → bucket [26, 30]
}
]
The _id of each output document is the lower boundary of the bucket. The default field catches any documents that don't fall into any defined bucket — if no documents fall outside, the default bucket is omitted from output.
Without it: you want to split users into 3 follower tiers, but you don't know where the natural breaks are. You'd sort all users by followersCount, divide into thirds, and compute the boundaries yourself.
What it does: automatically computes boundary values to distribute documents into approximately N equal-sized buckets. You specify the number of buckets; MongoDB figures out where to draw the lines.
Example — Auto-bucket users into 3 groups by followers
db.users.aggregate([ { $bucketAuto: { groupBy: "$followersCount", buckets: 3, output: { count: { $sum: 1 }, names: { $push: "$name" } } }} ])
[
{
_id: { min: 150, max: 1200 },
count: 3,
names: [ "Vihaan Gupta", "Kabir Singh", "Reyansh Joshi" ]
// followers: 150, 320, 600
},
{
_id: { min: 1200, max: 3300 },
count: 2,
names: [ "Aarav Sharma", "Ishita Verma" ]
// followers: 1200, 2100
},
{
_id: { min: 3300, max: 8900 },
count: 3,
names: [ "Myra Kapoor", "Diya Patel", "Ananya Rao" ]
// followers: 3300, 4500, 8900
}
]
Buckets may not be exactly equal. MongoDB distributes documents as evenly as it can, but if many documents share the same value, they all go into the same bucket. With 8 documents and 3 buckets, we get a 3-2-3 split. The _id now contains both min and max boundaries.
Accumulators are the operators that go inside $group's field definitions. They take a value from each document in the group and reduce them to a single output value. You've already seen $sum and $avg — here are all of them.
$sum
Adds up numeric values across all documents in the group. Pass 1 to count documents.
db.users.aggregate([ { $group: { _id: "$city", totalFollowers: { $sum: "$followersCount" } } }, { $sort: { totalFollowers: -1 } } ])
[
{ _id: "Indore", totalFollowers: 16700 }, // 4500 + 8900 + 3300
{ _id: "Bhopal", totalFollowers: 3620 }, // 1200 + 320 + 2100
{ _id: "Pune", totalFollowers: 750 } // 150 + 600
]
$avg
Computes the arithmetic mean of numeric values across the group.
db.users.aggregate([ { $group: { _id: "$city", avgFollowers: { $avg: "$followersCount" } } }, { $sort: { avgFollowers: -1 } } ])
[
{ _id: "Indore", avgFollowers: 5566.67 }, // 16700 / 3
{ _id: "Bhopal", avgFollowers: 1206.67 }, // 3620 / 3
{ _id: "Pune", avgFollowers: 375 } // 750 / 2
]
$min / $max
Returns the minimum / maximum value of the specified field across the group.
db.posts.aggregate([ { $group: { _id: "$category", minLikes: { $min: "$likesCount" }, maxLikes: { $max: "$likesCount" } }}, { $sort: { _id: 1 } } ])
[
{ _id: "art", minLikes: 310, maxLikes: 430 }, // p12(310), p5(430)
{ _id: "fitness", minLikes: 210, maxLikes: 780 }, // p4(210), p8(780)
{ _id: "food", minLikes: 1200, maxLikes: 2100 }, // p3(1200), p9(2100)
{ _id: "tech", minLikes: 340, maxLikes: 670 }, // p1(340), p10(670)
{ _id: "travel", minLikes: 890, maxLikes: 1450 } // p2(890), p7(1450)
]
$push
Collects a value from every document in the group into an array — no de-duplication.
db.comments.aggregate([ { $group: { _id: "$postId", commentTexts: { $push: "$text" } } }, { $match: { _id: "p3" } } ])
[
{
_id: "p3",
commentTexts: [
"Ok now I'm hungry",
"Which place exactly?",
"Facts, best in the city",
"Adding to my list"
]
// c6, c7, c8, c9 — p3's 4 comments, in insertion order
}
]
$addToSet
Like $push, but drops duplicate values — every element in the output array is unique.
db.posts.aggregate([ { $group: { _id: "$userId", categoriesPosted: { $addToSet: "$category" } } }, { $match: { _id: "u1" } } ])
[
{ _id: "u1", categoriesPosted: [ "tech" ] }
// u1 has p1 and p10, both category "tech" → deduplicated to one entry
]
$first / $last
Returns the value from the first / last document in each group, based on the document order the group received them in (usually you'll $sort before $group to make this meaningful).
db.posts.aggregate([ { $sort: { category: 1, createdAt: 1 } }, { $group: { _id: "$category", earliestPost: { $first: "$caption" }, latestPost: { $last: "$caption" } }} ])
[
{ _id: "art", earliestPost: "New charcoal sketch, took 6 hours", latestPost: "Digital art vs traditional, my take" },
{ _id: "fitness", earliestPost: "Leg day never lies", latestPost: "5AM workout hits different" },
{ _id: "food", earliestPost: "Best butter chicken in Indore...", latestPost: "Street food tour of Indore, thread" },
{ _id: "tech", earliestPost: "Deployed my first MERN app today!", latestPost: "MongoDB aggregation is actually beautiful" },
{ _id: "travel", earliestPost: "Sunset in Manali, worth every step", latestPost: "Backwaters of Kerala at dawn" }
]
$count (accumulator)
MongoDB 5.0+ shorthand for { $sum: 1 } inside $group — counts documents in each group without needing a field reference.
db.comments.aggregate([ { $group: { _id: "$postId", numComments: { $count: {} } } }, { $sort: { numComments: -1 } }, { $limit: 3 } ])
[
{ _id: "p3", numComments: 4 }, // c6, c7, c8, c9
{ _id: "p1", numComments: 3 }, // c1, c2, c3
{ _id: "p6", numComments: 3 } // c13, c14, c15
]
$stdDevPop / $stdDevSamp
Population / sample standard deviation of a numeric field across the group. Use $stdDevPop when your group IS the entire population; $stdDevSamp when your group is a sample of a larger population.
db.posts.aggregate([ { $group: { _id: null, avgLikes: { $avg: "$likesCount" }, likesStdDev: { $stdDevPop: "$likesCount" } }} ])
[
{ _id: null, avgLikes: 898.33, likesStdDev: 508.7 }
// _id: null groups ALL 12 posts into a single group
// high stdDev reflects the spread: likes range from 210 to 2100
]
| Operator | Returns | Common use |
|---|---|---|
| $sum | Total of a numeric field, or document count | Totals, counts |
| $avg | Arithmetic mean | Averages, ratings |
| $min / $max | Smallest / largest value | Ranges, leaderboards |
| $push | Array of all values (with duplicates) | Collecting related items |
| $addToSet | Array of unique values | Distinct tags, categories |
| $first / $last | Value from first / last doc in group order | Earliest/latest record per group |
| $count | Document count in the group | Shorthand for { $sum: 1 } |
| $stdDevPop / $stdDevSamp | Standard deviation | Spread / variance analysis |
Without them: you fetch raw numbers and do arithmetic in JS after the fact — meaning any computed value used for sorting or filtering has to happen client-side too, since the database never saw the derived number.
$add / $subtract / $multiply / $divide / $mod
Basic arithmetic. $add and $multiply take an array of 2+ values; $subtract, $divide, $mod take exactly 2 (in order — first minus/divided-by second).
db.posts.aggregate([ { $match: { _id: "p3" } }, { $project: { caption: 1, totalEngagement: { $add: ["$likesCount", "$commentsCount"] }, likesPerComment: { $divide: ["$likesCount", "$commentsCount"] }, doubledLikes: { $multiply: ["$likesCount", 2] }, likesMod100: { $mod: ["$likesCount", 100] } }} ])
[
{
caption: "Best butter chicken in Indore, change my mind",
totalEngagement: 1204, // 1200 + 4
likesPerComment: 300, // 1200 / 4
doubledLikes: 2400, // 1200 * 2
likesMod100: 0 // 1200 % 100
}
]
$round / $ceil / $floor / $trunc
Rounding controls. $round rounds to nearest (optionally to N decimal places), $ceil always rounds up, $floor always rounds down, $trunc chops decimals without rounding.
db.posts.aggregate([ { $match: { category: "food" } }, { $group: { _id: "$category", avgLikes: { $avg: "$likesCount" } } }, { $project: { avgLikes: 1, rounded: { $round: ["$avgLikes", 1] }, ceiling: { $ceil: "$avgLikes" }, floor: { $floor: "$avgLikes" } }} ])
[
{ _id: "food", avgLikes: 1650, rounded: 1650, ceiling: 1650, floor: 1650 }
]
// try this on "travel" instead — avgLikes 1096.666...:
// rounded: 1096.7, ceiling: 1097, floor: 1096, trunc: 1096
$abs
Returns the absolute value of a number — negatives become positive.
db.posts.aggregate([ { $match: { _id: { $in: ["p9", "p4"] } } }, { $project: { caption: 1, likesGapFromAvg: { $abs: { $subtract: ["$likesCount", 898.33] } } }} ])
[
{ caption: "Street food tour of Indore, thread", likesGapFromAvg: 1201.67 }, // |2100 - 898.33|
{ caption: "Leg day never lies", likesGapFromAvg: 688.33 } // |210 - 898.33|
]
Without them: comparisons inside $match use query operators like $gt, but inside $project/$addFields expressions you need the aggregation-expression versions of the same comparisons to build computed boolean fields.
$eq / $ne / $gt / $gte / $lt / $lte
Standard comparisons as expressions — each takes a 2-element array [value1, value2] and returns a boolean.
db.users.aggregate([ { $project: { name: 1, followersCount: 1, isInfluencer: { $gte: ["$followersCount", 3000] }, isNewbie: { $lt: ["$followersCount", 500] }, _id: 0 }} ])
[
{ name: "Aarav Sharma", followersCount: 1200, isInfluencer: false, isNewbie: false },
{ name: "Diya Patel", followersCount: 4500, isInfluencer: true, isNewbie: false },
{ name: "Kabir Singh", followersCount: 320, isInfluencer: false, isNewbie: true },
{ name: "Ananya Rao", followersCount: 8900, isInfluencer: true, isNewbie: false },
{ name: "Vihaan Gupta", followersCount: 150, isInfluencer: false, isNewbie: true },
{ name: "Ishita Verma", followersCount: 2100, isInfluencer: false, isNewbie: false },
{ name: "Reyansh Joshi", followersCount: 600, isInfluencer: false, isNewbie: false },
{ name: "Myra Kapoor", followersCount: 3300, isInfluencer: true, isNewbie: false }
]
$cmp
Three-way comparison. Returns -1 if first < second, 0 if equal, 1 if first > second. Rarely used directly — mostly informative for understanding how MongoDB sorts internally.
db.posts.aggregate([ { $match: { _id: { $in: ["p1", "p2"] } } }, { $project: { caption: 1, vsThreshold: { $cmp: ["$likesCount", 500] } }} ])
[
{ caption: "Deployed my first MERN app today!", vsThreshold: -1 }, // 340 < 500
{ caption: "Sunset in Manali, worth every step", vsThreshold: 1 } // 890 > 500
]
Without them: any "if this then that" logic on computed fields would require pulling documents into JS first, since $project/$addFields expressions have no native if-statement — these operators are how you branch inside the pipeline itself.
$and / $or / $not
Boolean combinators. $and/$or take an array of expressions; $not takes a single expression (wrapped in an array) and inverts it.
db.users.aggregate([ { $project: { name: 1, risingStar: { $and: [ { $eq: ["$isVerified", false] }, { $gte: ["$followersCount", 300] } ] }, _id: 0 }} ])
[
{ name: "Diya Patel", risingStar: false }, // verified
{ name: "Kabir Singh", risingStar: true }, // unverified, 320 followers
{ name: "Vihaan Gupta", risingStar: false }, // unverified but only 150
{ name: "Reyansh Joshi", risingStar: true }, // unverified, 600 followers
// ... remaining verified users all false
]
$cond
Inline if/else. Takes { if, then, else } or a 3-element array [condition, thenValue, elseValue].
db.posts.aggregate([ { $project: { caption: 1, likesCount: 1, status: { $cond: { if: { $gte: ["$likesCount", 1000] }, then: "viral", else: "normal" } } }} ])
[
{ caption: "Deployed my first MERN app today!", likesCount: 340, status: "normal" },
{ caption: "Sunset in Manali, worth every step", likesCount: 890, status: "normal" },
{ caption: "Best butter chicken in Indore, change my mind", likesCount: 1200, status: "viral" },
// ... p7 (1450) and p9 (2100) also "viral", rest "normal"
]
$ifNull
Returns the first non-null/non-missing expression. Common for supplying default values when a field might not exist on some documents.
db.posts.aggregate([ { $match: { _id: "p7" } }, { $project: { caption: 1, commentsCount: 1, displayComments: { $ifNull: ["$commentsCount", "No comments yet"] } }} ])
[
{ caption: "Road trip through Spiti valley", commentsCount: 0, displayComments: 0 }
]
// $ifNull only substitutes on null/missing, not on falsy values like 0.
// This trips up beginners who expect it to catch 0 or empty string too.
$switch
Multi-branch conditional — cleaner than nesting multiple $cond. Evaluates branches in order, uses default if none match.
db.posts.aggregate([ { $project: { caption: 1, likesCount: 1, tier: { $switch: { branches: [ { case: { $gte: ["$likesCount", 1500] }, then: "S" }, { case: { $gte: ["$likesCount", 800] }, then: "A" }, { case: { $gte: ["$likesCount", 400] }, then: "B" } ], default: "C" } } }}, { $sort: { likesCount: -1 } } ])
[
{ caption: "Street food tour of Indore, thread", likesCount: 2100, tier: "S" },
{ caption: "Road trip through Spiti valley", likesCount: 1450, tier: "A" },
{ caption: "Best butter chicken in Indore...", likesCount: 1200, tier: "A" },
{ caption: "Backwaters of Kerala at dawn", likesCount: 950, tier: "A" }
]
Without them: formatting, searching, or transforming text (capitalization, trimming, splitting usernames) all had to happen in JS after every document was fetched — you couldn't filter or sort on the transformed value at the database level.
$concat
Joins multiple strings into one. All arguments must resolve to strings — no automatic number-to-string conversion.
db.users.aggregate([ { $match: { _id: "u1" } }, { $project: { handle: { $concat: ["@", "$username", " · ", "$city"] }, _id: 0 }} ])
[ { handle: "@aarav_codes · Bhopal" } ]
$toUpper / $toLower
Case conversion.
db.posts.aggregate([ { $match: { _id: "p4" } }, { $project: { shout: { $toUpper: "$caption" }, categoryLower: { $toLower: "$category" }, _id: 0 }} ])
[ { shout: "LEG DAY NEVER LIES", categoryLower: "fitness" } ]
$trim
Strips whitespace (or specified characters) from both ends of a string. All our sample captions are already clean, so here's a synthetic value to show the behavior.
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { cleaned: { $trim: { input: " $caption " } }, _id: 0 }} ]) // note: input must be an expression, this shows the shape — in real use // you'd trim a field that actually has padding, e.g. user-submitted text
$substr / $substrBytes
Extracts a portion of a string given a start index and length. $substrBytes is byte-based (safe for ASCII); prefer $substrCP for multi-byte/unicode-safe extraction in modern MongoDB.
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { preview: { $substrBytes: ["$caption", 0, 20] }, _id: 0 }} ])
[ { preview: "Deployed my first ME" } ]
$split
Splits a string into an array using a delimiter.
db.users.aggregate([ { $match: { _id: "u4" } }, { $project: { usernameParts: { $split: ["$username", "."] }, _id: 0 }} ])
[ { usernameParts: [ "ananya", "eats" ] } ]
$strLenCP
Length of a string in Unicode code points (safe for counting characters, including multi-byte ones).
db.posts.aggregate([ { $project: { caption: 1, captionLength: { $strLenCP: "$caption" }, _id: 0 }}, { $sort: { captionLength: -1 } }, { $limit: 1 } ])
[ { caption: "Best butter chicken in Indore, change my mind", captionLength: 46 } ]
$strcasecmp
Case-insensitive string comparison. Returns -1, 0, or 1 like $cmp, but ignores case.
db.posts.aggregate([ { $match: { _id: "p6" } }, { $project: { matchesTech: { $strcasecmp: ["$category", "TECH"] }, _id: 0 }} ])
[ { matchesTech: 0 } ]
$regexMatch / $regexFind / $regexFindAll
$regexMatch returns a boolean. $regexFind returns the first match with its position. $regexFindAll returns every match.
db.posts.aggregate([ { $project: { caption: 1, mentionsIndore: { $regexMatch: { input: "$caption", regex: "Indore", options: "i" } }, _id: 0 }}, { $match: { mentionsIndore: true } } ])
[
{ caption: "Best butter chicken in Indore, change my mind", mentionsIndore: true },
{ caption: "Street food tour of Indore, thread", mentionsIndore: true }
]
| Operator | Purpose |
|---|---|
| $concat | Join strings together |
| $toUpper / $toLower | Change case |
| $trim | Remove leading/trailing characters |
| $substr / $substrBytes | Extract a portion of a string |
| $split | Break a string into an array |
| $strLenCP | Character count |
| $strcasecmp | Case-insensitive comparison |
| $regexMatch / $regexFind / $regexFindAll | Pattern matching |
Without them: to check array length, filter array contents, or transform elements, you'd fetch the document and manipulate the array in JS — meaning you can't sort or filter documents by a derived array property at the database level.
$size
Returns the number of elements in an array. You already saw this with $lookup results.
db.posts.aggregate([ { $project: { caption: 1, tagCount: { $size: "$tags" }, _id: 0 } }, { $sort: { tagCount: -1 } }, { $limit: 3 } ])
[
{ caption: "Deployed my first MERN app today!", tagCount: 3 },
{ caption: "Sunset in Manali, worth every step", tagCount: 3 },
{ caption: "Best butter chicken in Indore, change my mind", tagCount: 3 }
]
$arrayElemAt
Returns the element at a given index. Negative indices count from the end.
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { firstTag: { $arrayElemAt: ["$tags", 0] }, lastTag: { $arrayElemAt: ["$tags", -1] }, _id: 0 }} ])
[ { firstTag: "mern", lastTag: "nodejs" } ]
$slice
Returns a subset of an array — pass [array, n] for the first n elements, or [array, start, n] for n elements starting at an index.
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { firstTwoTags: { $slice: ["$tags", 2] }, _id: 0 }} ])
[ { firstTwoTags: [ "mern", "webdev" ] } ]
$filter
Filters array elements against a condition, similar to JS Array.filter() but running inside the pipeline.
db.posts.aggregate([ { $match: { _id: "p3" } }, { $project: { longTagsOnly: { $filter: { input: "$tags", as: "tag", cond: { $gt: [{ $strLenCP: "$$tag" }, 5] } } }, _id: 0 }} ])
[ { longTagsOnly: [ "indore", "foodie" ] } ]
$map
Transforms every element of an array, similar to JS Array.map().
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { hashtags: { $map: { input: "$tags", as: "tag", in: { $concat: ["#", "$$tag"] } } }, _id: 0 }} ])
[ { hashtags: [ "#mern", "#webdev", "#nodejs" ] } ]
$reduce
Folds an array down to a single value, accumulating as it goes — like JS Array.reduce(). Access the running total via $$value and the current element via $$this.
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { tagsJoined: { $reduce: { input: "$tags", initialValue: "", in: { $concat: [ "$$value", { $cond: [{ $eq: ["$$value", ""] }, "", ", "] }, "$$this" ] } } }, _id: 0 }} ])
[ { tagsJoined: "mern, webdev, nodejs" } ]
$in
Checks whether a value exists in an array. Returns a boolean.
db.posts.aggregate([ { $project: { caption: 1, isWebdev: { $in: ["webdev", "$tags"] }, _id: 0 }}, { $match: { isWebdev: true } } ])
[
{ caption: "Deployed my first MERN app today!", isWebdev: true },
{ caption: "Understanding closures finally clicked", isWebdev: true },
{ caption: "MongoDB aggregation is actually beautiful", isWebdev: true }
]
$indexOfArray
Returns the index of the first matching element, or -1 if not found.
db.posts.aggregate([ { $match: { _id: "p3" } }, { $project: { foodieIndex: { $indexOfArray: ["$tags", "foodie"] }, _id: 0 }} ])
[ { foodieIndex: 2 } ]
$concatArrays
Merges multiple arrays into one.
db.posts.aggregate([ { $match: { _id: { $in: ["p1", "p10"] } } }, { $group: { _id: "$userId", allTags: { $push: "$tags" } } }, { $project: { combinedTags: { $reduce: { input: "$allTags", initialValue: [], in: { $concatArrays: ["$$value", "$$this"] } } } }} ])
[
{
_id: "u1",
combinedTags: [ "mern", "webdev", "nodejs", "mongodb", "webdev", "backend" ]
// note: "webdev" appears twice — $concatArrays doesn't dedupe, unlike $addToSet
}
]
$mergeObjects
Merges multiple documents into one — later documents' fields override earlier ones on conflict. You saw this with $replaceRoot earlier.
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { merged: { $mergeObjects: [ { source: "user-generated", category: "$category" }, { category: "tech-verified" } ] }, _id: 0 }} ])
[ { merged: { source: "user-generated", category: "tech-verified" } } ]
Without them: "posts per month" or "users who joined this week" style reports require pulling every document and doing date math in JS — the database can't group by "month" without extracting it as a real value first.
Our sample dates are stored as strings (e.g. "2024-06-01") for readability in these notes. In the actual codebase, these fields are real BSON Date objects — date operators require actual Date types, not strings, so the seed script converts them on insert.
$year / $month / $dayOfMonth / $hour / $minute / $dayOfWeek
Extract individual components from a Date value.
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { year: { $year: "$createdAt" }, month: { $month: "$createdAt" }, day: { $dayOfMonth: "$createdAt" }, dayOfWeek: { $dayOfWeek: "$createdAt" }, _id: 0 }} ])
[ { year: 2024, month: 6, day: 1, dayOfWeek: 7 } ]
// $dayOfWeek: 1 = Sunday, 7 = Saturday
$dateToString
Formats a Date as a string using a format specifier, similar to strftime.
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { formatted: { $dateToString: { format: "%d %b %Y", date: "$createdAt" } }, _id: 0 }} ])
[ { formatted: "01 Jun 2024" } ]
$dateFromString
Parses a string into a Date value — the inverse of $dateToString.
db.posts.aggregate([ { $project: { parsedDate: { $dateFromString: { dateString: "2024-06-01" } }, _id: 0 }}, { $limit: 1 } ])
[ { parsedDate: ISODate("2024-06-01T00:00:00.000Z") } ]
$dateAdd / $dateSubtract
Adds or subtracts a duration from a date. Useful for computing "posted within last N days" windows.
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { weekLater: { $dateAdd: { startDate: "$createdAt", unit: "day", amount: 7 } }, _id: 0 }} ])
[ { weekLater: ISODate("2024-06-08T00:00:00.000Z") } ]
$dateDiff
Computes the difference between two dates in a specified unit.
db.posts.aggregate([ { $match: { _id: { $in: ["p1", "p12"] } } }, { $sort: { createdAt: 1 } }, { $group: { _id: null, first: { $first: "$createdAt" }, last: { $last: "$createdAt" } } }, { $project: { daysBetween: { $dateDiff: { startDate: "$first", endDate: "$last", unit: "day" } }, _id: 0 }} ])
[ { daysBetween: 15 } ]
$dateTrunc
Rounds a date down to the nearest unit (start of day, week, month, etc.) — the basis for "group posts by week" style reports.
db.posts.aggregate([ { $project: { weekStart: { $dateTrunc: { date: "$createdAt", unit: "week" } } }}, { $group: { _id: "$weekStart", postCount: { $sum: 1 } } }, { $sort: { _id: 1 } } ])
[
{ _id: ISODate("2024-05-26T00:00:00.000Z"), postCount: 4 }, // p1-p4 (Jun 1, 3, 5, 6)
{ _id: ISODate("2024-06-02T00:00:00.000Z"), postCount: 4 }, // p5-p8 (Jun 7, 8, 10, 11)
{ _id: ISODate("2024-06-09T00:00:00.000Z"), postCount: 4 } // p9-p12 (Jun 12, 14, 15, 16)
]
Without them: comparing or computing across mismatched types (a stringified number vs. a real number, for example) silently fails or produces wrong results in a pipeline — there's no implicit coercion the way JS does it.
$toString / $toInt / $toDouble / $toDate / $toBool / $toObjectId
Direct conversions to each named type.
db.users.aggregate([ { $match: { _id: "u1" } }, { $project: { followersAsString: { $toString: "$followersCount" }, ageAsDouble: { $toDouble: "$age" }, _id: 0 }} ])
[ { followersAsString: "1200", ageAsDouble: 22.0 } ]
$convert
Generic conversion — like the specific $to* operators, but lets you specify an onError and onNull fallback instead of throwing.
db.users.aggregate([ { $match: { _id: "u1" } }, { $project: { safeConvert: { $convert: { input: "$followersCount", to: "string", onError: "N/A", onNull: "unknown" } }, _id: 0 }} ])
[ { safeConvert: "1200" } ]
$type
Returns the BSON type name of a field's value as a string — useful for debugging inconsistent schemas.
db.users.aggregate([ { $match: { _id: "u1" } }, { $project: { followersType: { $type: "$followersCount" }, isVerifiedType: { $type: "$isVerified" }, _id: 0 }} ])
[ { followersType: "int", isVerifiedType: "bool" } ]
| Operator | Converts to |
|---|---|
| $toString | String |
| $toInt | 32-bit integer |
| $toDouble | Double-precision float |
| $toDate | Date |
| $toBool | Boolean |
| $toObjectId | ObjectId |
| $convert | Any type, with error/null fallbacks |
| $type | N/A — inspects and returns the current type name |
Without them: if the same computed sub-expression is needed twice inside one pipeline stage, you'd have to repeat the entire expression — verbose, and if you need to change it, you change it in two places.
$let
Defines one or more variables scoped to a single expression, avoiding repetition of a sub-computation.
db.posts.aggregate([ { $match: { _id: "p3" } }, { $project: { caption: 1, summary: { $let: { vars: { total: { $add: ["$likesCount", "$commentsCount"] } }, in: { $concat: ["Total engagement: ", { $toString: "$$total" }] } } }, _id: 0 }} ])
[
{
caption: "Best butter chicken in Indore, change my mind",
summary: "Total engagement: 1204"
}
]
$literal
Forces MongoDB to treat a value as literal, not as a field path or operator — needed when a literal string happens to start with $ and you don't want it interpreted as a field reference.
db.posts.aggregate([ { $match: { _id: "p1" } }, { $project: { literalDollarSign: { $literal: "$likesCount" }, // treated as the raw string, not a field path actualField: "$likesCount", // treated as a field reference _id: 0 }} ])
[ { literalDollarSign: "$likesCount", actualField: 340 } ]
Everything above was one stage or operator at a time. Real pipelines chain 4-8 stages together. Here are three end-to-end examples on our exact dataset — each one answers a question you genuinely could not answer with a single find() call.
Most-commented post per category, with commenter names
"For each category, which post got the most discussion — and who was talking?"
This needs: group posts by category, join in comment counts, pick the winner per category, then join in the actual commenter names. Five stages, two different $lookups.
db.posts.aggregate([ // Stage 1: attach the actual comment documents to each post { $lookup: { from: "comments", localField: "_id", foreignField: "postId", as: "postComments" }}, // Stage 2: compute a real comment count from the joined array { $addFields: { actualCommentCount: { $size: "$postComments" } } }, // Stage 3: sort within each category by comment count, so $first grabs the winner { $sort: { category: 1, actualCommentCount: -1 } }, // Stage 4: group by category, keep only the top post's data via $first { $group: { _id: "$category", caption: { $first: "$caption" }, commentCount: { $first: "$actualCommentCount" }, authorId: { $first: "$userId" }, commenterIds: { $first: "$postComments.userId" } }}, // Stage 5: join author details { $lookup: { from: "users", localField: "authorId", foreignField: "_id", as: "author" }}, { $unwind: "$author" }, // Stage 6: join commenter names { $lookup: { from: "users", localField: "commenterIds", foreignField: "_id", as: "commenters" }}, { $project: { _id: 0, category: "$_id", caption: 1, commentCount: 1, authorName: "$author.name", authorVerified: "$author.isVerified", commenterNames: "$commenters.name" }}, { $sort: { category: 1 } } ])
[
{
category: "art",
caption: "New charcoal sketch, took 6 hours",
commentCount: 2,
authorName: "Vihaan Gupta", authorVerified: false,
commenterNames: [ "Aarav Sharma", "Reyansh Joshi" ]
},
{
category: "fitness",
caption: "Leg day never lies",
commentCount: 1,
authorName: "Kabir Singh", authorVerified: false,
commenterNames: [ "Myra Kapoor" ]
},
{
category: "food",
caption: "Best butter chicken in Indore, change my mind",
commentCount: 4,
authorName: "Ananya Rao", authorVerified: true,
commenterNames: [ "Aarav Sharma", "Myra Kapoor", "Ishita Verma", "Kabir Singh" ]
},
{
category: "tech",
caption: "Deployed my first MERN app today!",
commentCount: 3,
authorName: "Aarav Sharma", authorVerified: true,
commenterNames: [ "Ishita Verma", "Kabir Singh", "Diya Patel" ]
},
{
category: "travel",
caption: "Sunset in Manali, worth every step",
commentCount: 2,
authorName: "Diya Patel", authorVerified: true,
commenterNames: [ "Aarav Sharma", "Ananya Rao" ]
}
]
Why $first after $sort, not $group directly: we sort by actualCommentCount descending within each category first, so when $group collapses each category, $first reliably grabs the highest-comment post's fields — not an arbitrary one.
City leaderboard — reach and top content per city
"Which city has the strongest community, and what's their best-performing post?"
This needs $facet-style thinking done manually with $lookup + $group: for each city, compute user stats, then separately find that city's best post, then merge the two.
db.users.aggregate([ // Stage 1: bring in every post by each user { $lookup: { from: "posts", localField: "_id", foreignField: "userId", as: "posts" }}, // Stage 2: flatten each user's posts into separate pipeline documents // (preserveNullAndEmptyArrays keeps users with zero posts in the pipeline) { $unwind: { path: "$posts", preserveNullAndEmptyArrays: true } }, // Stage 3: group back by city, computing city-level stats { $group: { _id: "$city", userCount: { $addToSet: "$_id" }, avgFollowers: { $avg: "$followersCount" }, topPostLikes: { $max: "$posts.likesCount" }, topPostCaption: { $first: { $cond: [{ $eq: ["$posts.likesCount", null] }, null, "$posts.caption"] } } }}, { $project: { _id: 0, city: "$_id", userCount: { $size: "$userCount" }, avgFollowers: { $round: ["$avgFollowers", 2] }, topPostLikes: 1 }}, { $sort: { avgFollowers: -1 } } ])
[
{ city: "Indore", userCount: 3, avgFollowers: 5566.67, topPostLikes: 2100 },
{ city: "Bhopal", userCount: 3, avgFollowers: 1206.67, topPostLikes: 670 },
{ city: "Pune", userCount: 2, avgFollowers: 375, topPostLikes: 950 }
]
// Indore wins on both reach and content performance:
// its top post is p9 "Street food tour of Indore, thread" at 2100 likes.
preserveNullAndEmptyArrays matters here. Without it, a user with zero posts would vanish entirely from the pipeline after $unwind — silently skewing avgFollowers since that user's follower count would never reach the $group stage. Every one of our 8 users has at least one post, so this doesn't change today's output, but it's a bug waiting to happen the moment your data changes.
Most engaged commenters, with their favorite category
"Who comments the most on this platform, and what kind of content do they engage with?"
This needs: group comments by commenter, join back to posts to find categories they commented on, then find their most-frequent category — a $lookup followed by a nested group-and-rank.
db.comments.aggregate([ // Stage 1: join each comment to the post it was made on { $lookup: { from: "posts", localField: "postId", foreignField: "_id", as: "post" }}, { $unwind: "$post" }, // Stage 2: group by commenter + category to count engagement per category { $group: { _id: { userId: "$userId", category: "$post.category" }, categoryCount: { $sum: 1 } }}, // Stage 3: sort so the top category per user comes first { $sort: { "_id.userId": 1, categoryCount: -1 } }, // Stage 4: re-group by user, take total comments + top category via $first { $group: { _id: "$_id.userId", totalComments: { $sum: "$categoryCount" }, favoriteCategory: { $first: "$_id.category" } }}, // Stage 5: attach the user's name { $lookup: { from: "users", localField: "_id", foreignField: "_id", as: "user" }}, { $unwind: "$user" }, { $project: { _id: 0, name: "$user.name", totalComments: 1, favoriteCategory: 1 }}, { $sort: { totalComments: -1 } } ])
[
{ name: "Aarav Sharma", totalComments: 4, favoriteCategory: "tech" }, // c4,c6,c11,c13
{ name: "Myra Kapoor", totalComments: 3, favoriteCategory: "food" }, // c7,c10,c15
{ name: "Ishita Verma", totalComments: 2, favoriteCategory: "tech" }, // c1,c8
{ name: "Kabir Singh", totalComments: 2, favoriteCategory: "tech" }, // c2,c9
{ name: "Diya Patel", totalComments: 1, favoriteCategory: "tech" }, // c3
{ name: "Ananya Rao", totalComments: 1, favoriteCategory: "travel" }, // c5
{ name: "Reyansh Joshi", totalComments: 1, favoriteCategory: "art" }, // c12
{ name: "Vihaan Gupta", totalComments: 1, favoriteCategory: "tech" } // c14
]
The first $group answers "how many times did this user comment on this category" — a fine-grained count. The second $group collapses those category-level counts back up to one row per user, using $sum to total all their comments and $first (after sorting) to grab their single most-commented category. This two-pass group pattern — group fine, sort, group coarse — is one of the most useful tricks in aggregation once you need a "top X per Y" answer.