Database NotesRohit’s interview study guide
Chapter 19

Aggregation Pipeline

MongoDB's answer to GROUP BY, joins and reporting queries: documents flow through a list of stages, each one reshaping the stream for the next.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#How a pipeline works

find() can filter, project and sort, but it can't compute across documents: it can't total an order, count orders per customer or join two collections. For that MongoDB has the aggregation pipeline: an array of stages you pass to db.collection.aggregate([...]). Documents from the collection enter the first stage; whatever that stage outputs becomes the input of the next stage; the output of the last stage is the result you get back, as a cursor.

The mental model is a factory assembly line. Each stage is one station with one job: $match throws away documents you don't want, $group piles documents into bins and replaces each bin with a summary document, $project reshapes each document, $sort reorders the line. A station only ever sees what the station before it passed down. That's the single most useful fact about pipelines: the order of stages is the order of work, and a field that an earlier stage removed or renamed simply doesn't exist further down.

If you know SQL, the stages map closely onto clauses: $match is WHERE (or HAVING when it comes after $group), $group is GROUP BY with its aggregate functions, $project is the SELECT list, $sort/$skip/$limit are ORDER BY/OFFSET/LIMIT, $lookup is a LEFT OUTER JOIN. The difference is that in SQL the clause order you write is rearranged into a fixed logical order, while in a pipeline you write the order yourself, and you can repeat stages ($match, then $group, then $match again).

The examples on this page use the site's sample data as MongoDB collections in a database called ch19: customers, products, employees, and orders, which embed their line items (items: [{ productId, qty, unitPrice }]) instead of keeping them in a separate table. This is the "interesting example" from my notes, rewritten on that data: shipped orders per customer.

mongosh
db.orders.aggregate([
  { $match: { status: "shipped" } },
  { $group: {
      _id: "$customerId",
      totalOrders: { $sum: 1 },
      firstStatus: { $first: "$status" }
  } },
  { $project: { _id: 0, customerId: "$_id", totalOrders: 1, firstStatus: 1 } },
  { $sort: { totalOrders: -1, customerId: 1 } }
])
Result
[
  { totalOrders: 2, firstStatus: 'shipped', customerId: 2 },
  { totalOrders: 1, firstStatus: 'shipped', customerId: 1 },
  { totalOrders: 1, firstStatus: 'shipped', customerId: 3 }
]
What’s happening
  1. $match runs first and keeps only orders whose status is "shipped": 8 orders go in, 4 come out (_id 1, 3, 5 and 8, belonging to customers 1, 2, 3 and 2).
  2. $group sorts those 4 documents into bins by the value of _id: "$customerId". The $ in "$customerId" means "the value of the field", not the literal string. Three bins form: customer 1 (1 order), customer 2 (2 orders), customer 3 (1 order).
  3. Each bin becomes one new document. totalOrders: { $sum: 1 } adds 1 per document in the bin (a count); firstStatus: { $first: "$status" } takes the status of the first document that reached the bin. After the $match every status is "shipped", so this field is constant here; it's in my notes to show that a grouped document has no fields except _id and the ones you compute.
  4. $project reshapes each summary: _id: 0 drops the group key, customerId: "$_id" copies it into a properly named field, and 1 keeps the two computed fields. Fields kept with 1 come first in their existing order, then the new computed field, which is why customerId prints last.
  5. $sort orders by totalOrders descending, then customerId ascending to break the tie between customers 1 and 3. Without that second key, the order of tied documents isn't guaranteed.
AdvancedWhat the stream looks like between stages

Running the first stages on their own shows what each station hands to the next. This is also the best way to debug a pipeline: cut it off after stage n and look.

mongosh
db.orders.aggregate([ { $match: { status: "shipped" } }, { $project: { customerId: 1, status: 1 } } ])
db.orders.aggregate([
  { $match: { status: "shipped" } },
  { $group: { _id: "$customerId", totalOrders: { $sum: 1 }, firstStatus: { $first: "$status" } } }
])
Result
[
  { _id: 1, customerId: 1, status: 'shipped' },
  { _id: 3, customerId: 2, status: 'shipped' },
  { _id: 5, customerId: 3, status: 'shipped' },
  { _id: 8, customerId: 2, status: 'shipped' }
]
[
  { _id: 3, totalOrders: 1, firstStatus: 'shipped' },
  { _id: 1, totalOrders: 1, firstStatus: 'shipped' },
  { _id: 2, totalOrders: 2, firstStatus: 'shipped' }
]
What’s happening
  1. After $match, the documents are still whole orders (projected here only to keep the output short). The _id is the order id.
  2. After $group, they are completely different documents: _id is now the customer id, and the only other fields are the accumulators. The original status, items and orderedAt fields are gone unless an accumulator carried them through.
  3. The groups came out in the order 3, 1, 2. $group doesn't sort its output; the order depends on how the server's hash table happened to fill. If the order matters, add a $sort after it, as the main example does.
AdvancedEdge case: aggregate on the database, not a collection

aggregate() usually runs on a collection, but db.aggregate() runs a pipeline that doesn't start from a collection. $documents (MongoDB 5.1+) feeds literal documents in, which is handy for trying out an expression without creating test data:

mongosh
db.aggregate([ { $documents: [ { x: 1 }, { x: 2 } ] }, { $set: { double: { $multiply: ["$x", 2] } } } ])
Result
[ { x: 1, double: 2 }, { x: 2, double: 4 } ]
What’s happening
  1. $documents must be the first stage of a db.aggregate() call; it emits the two literal documents into the pipeline.
  2. $set (an alias of $addFields) computes double from each document's x: 1 → 2, 2 → 4.
  3. The same db.aggregate() form is used for admin pipelines such as $currentOp and $listLocalSessions, which read server state rather than a collection.
AdvancedWhat aggregate() returns

aggregate() returns a cursor, just like find(). mongosh prints the first batch for you, but in application code you iterate it or call .toArray(). Results larger than one batch (101 documents in the first batch by default, then up to 16 MB per batch) are fetched lazily as you iterate. Every single document a pipeline returns must fit in the 16 MB BSON limit; $facet is where people usually hit that.

Predict the result: the stages are swapped
mongosh
db.orders.aggregate([
  { $group: { _id: "$customerId", totalOrders: { $sum: 1 } } },
  { $match: { status: "shipped" } }
]).toArray()
Show answer

[]: an empty array, with no error.

What’s happening
  1. $group runs first over all 8 orders and outputs 5 documents shaped { _id: <customerId>, totalOrders: <n> }.
  2. Those documents have no status field: $group only outputs _id and the accumulators you asked for.
  3. $match: { status: "shipped" } compares a missing field with "shipped", which is false for every document, so nothing survives.
  4. MongoDB doesn't warn you, because a missing field is a perfectly legal thing to match on. The fix is to put the $match first (it then acts like SQL WHERE) or, if you meant to filter groups, match on a field the group produced ({ totalOrders: { $gte: 2 } }, which acts like HAVING).

#$match and $project

$match and $project are the two stages that most pipelines start and end with. $match filters documents using exactly the same query language as find(): { status: "shipped" }, { price: { $lt: 1200 } }, $in, $or, $elemMatch and so on all work unchanged. $project reshapes documents: it decides which fields come out, renames them, and computes new ones from expressions.

Think of $match as the bouncer at the door (it decides who gets in, and never changes anyone) and $project as the tailor (everyone who gets in comes out wearing a different outfit, but nobody is turned away). One changes the number of documents, the other changes their shape. Keeping that straight tells you where each belongs: $match as early as possible, because every document it removes is work no later stage has to do; $project usually at the end, to shape what goes back to the client.

This covers the projection example from my notes (include name and price, drop _id, add an upper-cased category) on the products collection:

mongosh
db.products.aggregate([
  { $match: { category: { $in: ["Laptops", "Phones"] }, price: { $lt: 1200 } } },
  { $project: {
      _id: 0,
      name: 1,
      price: 1,
      newCategory: { $toUpper: "$category" },
      inStock: { $gt: ["$stock", 0] }
  } }
])
Result
[
  {
    name: 'ThinkBook 14',
    price: 899,
    newCategory: 'LAPTOPS',
    inStock: true
  },
  { name: 'Pixel 10', price: 799, newCategory: 'PHONES', inStock: true },
  {
    name: 'iPhone 17',
    price: 1099,
    newCategory: 'PHONES',
    inStock: false
  }
]
What’s happening
  1. $match uses ordinary query syntax: the category must be one of the two values and (two conditions in one object are an implicit AND) the price must be under 1200. Of the four laptops and phones, the MacBook Air (1299) fails the price test; three products pass.
  2. $project builds a new document for each. _id: 0 excludes the _id, which is otherwise always included; name: 1 and price: 1 copy those fields through unchanged.
  3. newCategory: { $toUpper: "$category" } is a computed field: "$category" reads the field's value and $toUpper converts it, so "Laptops" becomes "LAPTOPS".
  4. inStock: { $gt: ["$stock", 0] } is the aggregation form of $gt: an array of two expressions that returns true or false. The iPhone has stock: 0, so it gets false. Note this is a different syntax from the query form { stock: { $gt: 0 } } used in $match.
  5. stock and category aren't mentioned with 1, so they're dropped: in an inclusion projection, anything you don't name is left out.
AdvancedInclusion and exclusion can't be mixed

A projection is either an inclusion (list what to keep) or an exclusion (list what to drop). The one field allowed to break the rule is _id, which you can exclude from an inclusion projection, as above.

mongosh
db.products.aggregate([ { $project: { name: 1, stock: 0 } } ])
db.products.aggregate([ { $match: { category: "Books" } }, { $project: { stock: 0, category: 0 } } ])
db.products.aggregate([ { $match: { category: "Books" } }, { $project: { name: 1 } } ])
Result
Uncaught MongoServerError[Location31254]: Invalid $project :: caused by :: Cannot do exclusion on field stock in inclusion projection
[
  { _id: 5, name: 'SQL Performance Explained', price: 39 },
  { _id: 6, name: 'Designing Data-Intensive Applications', price: 49 }
]
[
  { _id: 5, name: 'SQL Performance Explained' },
  { _id: 6, name: 'Designing Data-Intensive Applications' }
]
What’s happening
  1. { name: 1, stock: 0 } asks for "only name" and "everything except stock" at the same time, which has no meaning, so the server rejects it with error 31254.
  2. The exclusion form { stock: 0, category: 0 } keeps every other field: _id, name, price.
  3. The inclusion form { name: 1 } keeps only name, plus _id, which is included unless you say _id: 0. My notes say the same about find() projections: _id is the only field you have to switch off explicitly.
  4. $unset: ["stock", "category"] is a shorter way to write the exclusion form, and $set/$addFields is the way to add fields without listing all the others (see Expressions and $addFields).
Advanced$match can't use aggregation expressions directly

$match takes the query language, not the aggregation expression language. To compare two fields of the same document, or to use any aggregation operator, wrap it in $expr:

mongosh
db.products.aggregate([ { $match: { $gt: ["$stock", 10] } } ])
db.products.aggregate([
  { $match: { $expr: { $gt: ["$stock", "$price"] } } },
  { $project: { name: 1, stock: 1, price: 1 } }
])
Result
Uncaught MongoServerError[BadValue]: unknown top level operator: $gt. If you have a field name that starts with a '$' symbol, consider using $getField or $setField.
[
  { _id: 5, name: 'SQL Performance Explained', price: 39, stock: 50 },
  { _id: 7, name: 'USB-C Charger', price: 29, stock: 100 }
]
What’s happening
  1. In the query language, $gt is only valid under a field name ({ stock: { $gt: 10 } }). At the top level it's an unknown operator, hence BadValue.
  2. $expr switches into expression mode, where $gt takes two expressions. ["$stock", "$price"] compares two fields of the same document, which the plain query language can't do.
  3. Only the two cheap items have more units in stock than their price. This is the same $expr from $expr: comparing fields (my notes' "volume greater than target" example), just inside a pipeline.
  4. Performance note: a plain { field: value } $match at the start of a pipeline can use an index; $expr can use one only for simple equality and range comparisons against constants, never for field-to-field comparisons.
AdvancedLiteral values and $$REMOVE

In $project, a bare 1 or true means "include this field", so you can't write a literal number as a value. Wrap literals in $literal, and use the $$REMOVE variable to drop a field conditionally:

mongosh
db.products.aggregate([
  { $match: { category: "Books" } },
  { $project: { name: 1, price: "$$REMOVE", label: { $literal: 1 } } }
])
Result
[
  { _id: 5, name: 'SQL Performance Explained', label: 1 },
  { _id: 6, name: 'Designing Data-Intensive Applications', label: 1 }
]
What’s happening
  1. name: 1 includes name; without $literal, label: 1 would mean "include a field called label", which doesn't exist, so nothing would appear.
  2. { $literal: 1 } produces the constant 1, so every document gets label: 1.
  3. price: "$$REMOVE" evaluates to "no value", so price is left out. On its own this is the same as not listing price; it becomes useful inside $cond, e.g. show price only when stock > 0.
Spot the bug: why is this pipeline slow?
mongosh
db.users.aggregate([
  { $project: { name: 1, city: 1, score: 1 } },
  { $sort: { score: -1 } },
  { $match: { city: "Toronto" } },
  { $limit: 10 }
])
Show answer

On paper it sorts every user before filtering; in practice MongoDB's optimiser moves the $match to the front, because it only uses a field (city) that the earlier $project didn't compute.

What’s happening
  1. As written, $project and $sort would process all 100,000 users before $match threw most of them away.
  2. The optimiser checks each $match filter: if it doesn't depend on a field created by an earlier projection stage, it moves that filter in front of the projection. city is an original field, so the $match becomes stage one and can use an index on city.
  3. explain() on this pipeline confirms it: the initial cursor's filter is { city: { '$eq': 'Toronto' } } with an IXSCAN, the $project was folded into the cursor's projection, and the only stage left after it is $sort with limit: 10 coalesced in.
  4. Don't rely on it, though. A filter on a computed field (say ageBand from an $addFields) can't move, and the reordering can't get past stages like $group or $unwind. Write $match first yourself. Pipeline performance shows the explain output for this.

#$group and accumulators

$group is the pipeline's GROUP BY. It reads every document that reaches it, works out a group key from the _id expression, and keeps one bucket per distinct key. When its input is exhausted, it outputs one document per bucket: _id holds the key, and every other field is an accumulator you declared ($sum, $avg, $min, $max, $first, $last, $push, $addToSet, $count, and newer ones like $topN and $median).

Picture a sorting office with a row of pigeonholes. Each incoming document is read for its key (say, category) and dropped into that pigeonhole. Each pigeonhole keeps only running totals, not the documents themselves: a counter for $sum, a running total and count for $avg, the smallest value so far for $min. At the end, each pigeonhole is replaced by a card with its totals written on it. Because only the cards come out, any field you don't accumulate is lost, which is the most common $group surprise.

_id is mandatory (my notes stress it). Its value can be a field path ("$category"), a document of several paths for a multi-field key, or null to put everything into one group. This also covers the "multiply 2 columns and sum them, grouped by category" example from my notes: the value of stock held per category is price × stock, summed.

mongosh
db.products.aggregate([
  { $group: {
      _id: "$category",
      products: { $sum: 1 },
      avgPrice: { $avg: "$price" },
      cheapest: { $min: "$price" },
      stockValue: { $sum: { $multiply: ["$price", "$stock"] } },
      names: { $push: "$name" }
  } },
  { $sort: { stockValue: -1 } }
])
Result
[
  {
    _id: 'Laptops',
    products: 2,
    avgPrice: 1099,
    cheapest: 899,
    stockValue: 17283,
    names: [ 'ThinkBook 14', 'MacBook Air' ]
  },
  {
    _id: 'Phones',
    products: 2,
    avgPrice: 949,
    cheapest: 799,
    stockValue: 15980,
    names: [ 'Pixel 10', 'iPhone 17' ]
  },
  {
    _id: 'Accessories',
    products: 2,
    avgPrice: 139,
    cheapest: 29,
    stockValue: 4892,
    names: [ 'USB-C Charger', 'Noise-cancelling Headphones' ]
  },
  {
    _id: 'Books',
    products: 2,
    avgPrice: 44,
    cheapest: 39,
    stockValue: 3665,
    names: [
      'SQL Performance Explained',
      'Designing Data-Intensive Applications'
    ]
  }
]
What’s happening
  1. _id: "$category" is the group key: the 8 products fall into 4 buckets of 2 (Laptops, Phones, Books, Accessories).
  2. For each document entering a bucket, every accumulator updates. For Laptops: the ThinkBook arrives (products 0 → 1, cheapest 899, stockValue 0 → 899 × 12 = 10788), then the MacBook (products 1 → 2, cheapest stays 899, stockValue 10788 → 10788 + 1299 × 5 = 17283).
  3. $avg keeps a running sum and count and divides at the end: (899 + 1299) / 2 = 1099.
  4. $sum accepts any expression, not just a field. $multiply: ["$price", "$stock"] is evaluated per document and its result is added; $sum: 1 is the classic way to count documents.
  5. $push appends each document's name to an array, so the group still knows which products it contains. It keeps duplicates; $addToSet would drop them.
  6. $sort orders the four summary documents by stockValue. Remember $group output is unordered, so the sort is what makes this a "top categories" report.
AdvancedNo _id, everything in one group, and multi-field keys

Three variations of the key, and the error you get without one:

mongosh
db.products.aggregate([ { $group: { total: { $sum: "$stock" } } } ])
db.products.aggregate([
  { $group: { _id: null, totalStock: { $sum: "$stock" }, avgPrice: { $avg: "$price" } } }
])
db.employees.aggregate([
  { $group: { _id: "$department", headcount: { $sum: 1 },
              avgSalary: { $avg: "$salary" }, maxSalary: { $max: "$salary" } } },
  { $sort: { _id: 1 } }
])
db.orders.aggregate([
  { $group: { _id: { customer: "$customerId", status: "$status" }, n: { $count: {} } } },
  { $sort: { "_id.customer": 1, "_id.status": 1 } }
])
Result
Uncaught MongoServerError[Location15955]: a group specification must include an _id
[ { _id: null, totalStock: 230, avgPrice: 557.75 } ]
[
  { _id: null, headcount: 1, avgSalary: 91000, maxSalary: 91000 },
  {
    _id: 'Engineering',
    headcount: 4,
    avgSalary: 146750,
    maxSalary: 185000
  },
  { _id: 'Sales', headcount: 3, avgSalary: 108000, maxSalary: 130000 },
  { _id: 'Support', headcount: 2, avgSalary: 80000, maxSalary: 88000 }
]
[
  { _id: { customer: 1, status: 'paid' }, n: 1 },
  { _id: { customer: 1, status: 'shipped' }, n: 1 },
  { _id: { customer: 2, status: 'shipped' }, n: 2 },
  { _id: { customer: 3, status: 'cancelled' }, n: 1 },
  { _id: { customer: 3, status: 'shipped' }, n: 1 },
  { _id: { customer: 4, status: 'pending' }, n: 1 },
  { _id: { customer: 5, status: 'paid' }, n: 1 }
]
What’s happening
  1. Leaving out _id is error 15955, "a group specification must include an _id". It's mandatory even when you want a single total.
  2. _id: null puts every document in the same bucket: one result for the whole collection, like SQL aggregates without GROUP BY. Total stock is 230, average price 557.75.
  3. Grouping employees by department shows how missing values behave: Jonas has department: null, so he gets his own group with _id: null. A document with the field missing entirely would land in the same null group, just like SQL's GROUP BY puts NULLs together.
  4. A document as _id makes a composite key: one group per distinct (customer, status) pair. You sort on its parts with dotted paths like "_id.customer".
  5. $count: {} (MongoDB 5.0+) is the accumulator form of counting, identical to $sum: 1. Don't confuse it with the $count stage, which counts the whole stream: { $count: "orders" } returns [ { orders: 8 } ].
Advanced$first and $last depend on the order you feed in

$first and $last take the value from whichever document reached the bucket first or last. Without a $sort before the $group, that's just the order documents happened to be read in, so the result is meaningless. Sort first:

mongosh
db.employees.aggregate([
  { $match: { department: { $ne: null } } },
  { $sort: { salary: -1, _id: 1 } },
  { $group: { _id: "$department", topEarner: { $first: "$name" }, lowestPaid: { $last: "$name" } } },
  { $sort: { _id: 1 } }
])
Result
[
  { _id: 'Engineering', topEarner: 'Asha', lowestPaid: 'Dev' },
  { _id: 'Sales', topEarner: 'Elena', lowestPaid: 'Farid' },
  { _id: 'Support', topEarner: 'Hiro', lowestPaid: 'Isla' }
]
What’s happening
  1. $match drops Jonas (no department) so he doesn't form a null group.
  2. $sort puts everyone in descending salary order, with _id as a tie-breaker for Ben and Chloe (both 142,000).
  3. $group preserves the order in which it receives documents within each bucket, so the first Engineering document is Asha (185,000) and the last is Dev (118,000).
  4. This is a "top 1 per group" query. With an index on { department: 1, salary: -1 } and a pipeline that groups using only $first/$last, the server can even answer it by jumping through the index (a DISTINCT_SCAN) instead of reading every document.
AdvancedNewer accumulators: $topN, $median and $addToSet

MongoDB 5.2 added $topN, $bottomN, $firstN, $lastN, $maxN and $minN, which carry their own sort so you don't depend on input order. MongoDB 7.0 added $median and $percentile.

mongosh
db.employees.aggregate([
  { $match: { department: { $ne: null } } },
  { $group: {
      _id: "$department",
      top2: { $topN: { n: 2, sortBy: { salary: -1 }, output: { name: "$name", salary: "$salary" } } },
      median: { $median: { input: "$salary", method: "approximate" } }
  } },
  { $sort: { _id: 1 } }
])
db.orders.aggregate([
  { $group: { _id: "$customerId", statuses: { $addToSet: "$status" } } },
  { $sort: { _id: 1 } }
])
Result
[
  {
    _id: 'Engineering',
    top2: [ { name: 'Asha', salary: 185000 }, { name: 'Ben', salary: 142000 } ],
    median: 142000
  },
  {
    _id: 'Sales',
    top2: [ { name: 'Elena', salary: 130000 }, { name: 'Grace', salary: 99000 } ],
    median: 99000
  },
  {
    _id: 'Support',
    top2: [ { name: 'Hiro', salary: 88000 }, { name: 'Isla', salary: 72000 } ],
    median: 72000
  }
]
[
  { _id: 1, statuses: [ 'paid', 'shipped' ] },
  { _id: 2, statuses: [ 'shipped' ] },
  { _id: 3, statuses: [ 'shipped', 'cancelled' ] },
  { _id: 4, statuses: [ 'pending' ] },
  { _id: 5, statuses: [ 'paid' ] }
]
What’s happening
  1. $topN keeps the 2 best documents per bucket by its own sortBy, so it doesn't need a $sort stage before the group and it only holds n items in memory per group.
  2. $median with method: "approximate" (the only method in 7.0+) returns a value that occurs in the data. Support has 88,000 and 72,000; a textbook median would be 80,000, but MongoDB returns 72,000. Know this before you put it in a report.
  3. $addToSet collects distinct values per group. Maya (customer 2) has two shipped orders but statuses holds 'shipped' once. Element order in the set isn't guaranteed.
  4. Customer 6 (Zara) is missing from the second result because she has no orders: $group can only form groups from documents that exist. To list customers with zero orders you start from customers and use $lookup.
AdvancedWhen you don't want to collapse rows: $setWindowFields

$group replaces many documents with one. When you want per-document results alongside group values (a rank, a running total, the department average next to each salary), use $setWindowFields (MongoDB 5.0+), the equivalent of SQL window functions (see Window functions: OVER and PARTITION BY):

mongosh
db.employees.aggregate([
  { $match: { department: "Engineering" } },
  { $setWindowFields: {
      partitionBy: "$department",
      sortBy: { salary: -1 },
      output: { rank: { $rank: {} }, deptAvg: { $avg: "$salary" } }
  } },
  { $project: { _id: 0, name: 1, salary: 1, rank: 1, deptAvg: 1 } }
])
Result
[
  { name: 'Asha', salary: 185000, rank: 1, deptAvg: 146750 },
  { name: 'Ben', salary: 142000, rank: 2, deptAvg: 146750 },
  { name: 'Chloe', salary: 142000, rank: 2, deptAvg: 146750 },
  { name: 'Dev', salary: 118000, rank: 4, deptAvg: 146750 }
]
What’s happening
  1. partitionBy plays the role of $group's _id, but the four Engineering documents all survive.
  2. $rank numbers them in sortBy order with gaps for ties: Ben and Chloe share rank 2, so Dev is 4 ($denseRank would give 3).
  3. deptAvg uses no window bounds, so it averages the whole partition and every document gets the same 146,750.
Predict the result: averaging a field that isn't always there
mongosh
db.vals.insertMany([ { a: 10 }, { a: 20 }, { a: null }, { } ])
db.vals.aggregate([ { $group: { _id: null, avg: { $avg: "$a" }, sum: { $sum: "$a" }, n: { $sum: 1 } } } ])
Show answer

{ _id: null, avg: 15, sum: 30, n: 4 }.

What’s happening
  1. Four documents enter the single group, so n counts all four.
  2. $avg ignores non-numeric values: null and the missing field don't count, so it averages 10 and 20 → 15, not 30 / 4 = 7.5.
  3. $sum also ignores non-numbers, giving 30. (Had every value been missing, $sum would return 0 and $avg would return null.)
  4. SQL's AVG behaves the same way with NULLs. If you want missing values to count as zero, write $avg: { $ifNull: ["$a", 0] }.

#$sort, $limit and $skip

Added

These three stages work like .sort(), .limit() and .skip() on a find() cursor, but inside a pipeline they're ordinary stages, so where you put them matters. $sort reorders the stream by one or more keys (1 ascending, -1 descending). $limit: n passes the first n documents and stops. $skip: n throws away the first n documents and passes the rest.

The analogy is a queue at a ticket office: $sort lines people up, $skip sends the first few home, $limit lets the next few in and closes the doors. Swap $limit and $sort and you get a different crowd: "the first three people who arrived, lined up by height" is not "the three tallest people".

mongosh
db.products.aggregate([
  { $sort: { price: -1 } },
  { $skip: 2 },
  { $limit: 3 },
  { $project: { _id: 0, name: 1, price: 1 } }
])
Result
[
  { name: 'ThinkBook 14', price: 899 },
  { name: 'Pixel 10', price: 799 },
  { name: 'Noise-cancelling Headphones', price: 249 }
]
What’s happening
  1. $sort orders all 8 products by price, highest first: MacBook Air 1299, iPhone 17 1099, ThinkBook 899, Pixel 799, Headphones 249, then the cheap items.
  2. $skip: 2 discards the first two (MacBook and iPhone): this is "page 2" when a page holds 2 items, or "results 3 to 5" here.
  3. $limit: 3 passes the next three and the pipeline stops pulling documents.
  4. Internally the optimiser merges $sort with the $limit: it knows only the top 2 + 3 = 5 documents can ever matter, so it keeps a 5-item top-k buffer instead of sorting everything. The $skip then runs on those 5.
AdvancedTies make pages unstable: add a unique tie-breaker

When several documents share a sort value, MongoDB returns them in no particular order, and that order can change between runs and between pages. For anything paged, add a unique field (usually _id) as the last sort key:

mongosh
db.employees.aggregate([ { $sort: { salary: -1 } }, { $limit: 3 }, { $project: { _id: 0, name: 1, salary: 1 } } ])
db.employees.aggregate([
  { $sort: { salary: -1, _id: 1 } },
  { $skip: 1 },
  { $limit: 2 },
  { $project: { name: 1, salary: 1 } }
])
Result
[
  { name: 'Asha', salary: 185000 },
  { name: 'Ben', salary: 142000 },
  { name: 'Chloe', salary: 142000 }
]
[
  { _id: 2, name: 'Ben', salary: 142000 },
  { _id: 3, name: 'Chloe', salary: 142000 }
]
What’s happening
  1. Ben and Chloe both earn 142,000. In the first query they came out Ben-then-Chloe, but nothing guarantees it. With $limit: 2 you might get either of them as the second row.
  2. Adding _id: 1 as a second key makes the order total: Ben (_id 2) always comes before Chloe (_id 3).
  3. With a total order, $skip/$limit pages never overlap or miss a document. For deep pages, prefer keyset paging ($match: { salary: { $lt: lastSalary } }, or the _id equivalent) over large $skip values, for the same reason as in SQL: see Pagination with skip and limit.
Advanced$sortByCount: group, count and sort in one stage

Counting by a field and sorting by the count is so common it has its own stage:

mongosh
db.orders.aggregate([ { $sortByCount: "$status" } ])
Result
[
  { _id: 'shipped', count: 4 },
  { _id: 'paid', count: 2 },
  { _id: 'cancelled', count: 1 },
  { _id: 'pending', count: 1 }
]
What’s happening
  1. $sortByCount: "$status" is shorthand for { $group: { _id: "$status", count: { $sum: 1 } } } followed by { $sort: { count: -1 } }.
  2. Ties (cancelled and pending, 1 each) have no guaranteed order, the same as any $sort on a non-unique key.
AdvancedSorts are limited to 100 MB of memory

A blocking $sort (one an index can't satisfy) has to hold its input in memory. Each stage gets 100 MB; beyond that it must either spill to temporary files on disk or fail. Since MongoDB 6.0 the server parameter allowDiskUseByDefault is true, so it spills automatically; before 6.0 you had to pass { allowDiskUse: true } or get an error. Here 100,000 generated users are padded with 1.5 KB each to push a sort past the limit:

mongosh
const pad = "x".repeat(1500)
const p = [ { $set: { pad: pad } }, { $sort: { score: 1, email: 1 } }, { $limit: 100000 }, { $count: "n" } ]
db.users.aggregate(p, { allowDiskUse: false })
db.users.aggregate(p)
db.users.explain("executionStats").aggregate(p).stages.filter(s => s.$sort)
Result
Uncaught MongoServerError[QueryExceededMemoryLimitNoDiskUseAllowed]: Executor error during aggregate command on namespace: ch19.users :: caused by :: Sort exceeded memory limit of 104857600 bytes, but did not opt in to external sorting. Aborting operation. Pass allowDiskUse:true to opt in.
[ { n: 100000 } ]
[
  {
    '$sort': { sortKey: { score: 1, email: 1 }, limit: Long('100000') },
    totalDataSizeSortedBytesEstimate: Long('192977790'),
    usedDisk: true,
    spills: Long('2'),
    spilledBytes: Long('159277790'),
    spilledRecords: Long('100000'),
    spilledDataStorageSize: Long('8633343'),
    nReturned: Long('100000'),
    executionTimeMillisEstimate: Long('236'),
    peakTrackedMemBytes: Long('103807928')
  }
]
What’s happening
  1. The sort has to see about 193 MB of documents (totalDataSizeSortedBytesEstimate). With allowDiskUse: false, it fails as soon as it passes 104,857,600 bytes (100 MiB).
  2. Without the option, the 9.0 default applies (allowDiskUseByDefault: true) and the query succeeds.
  3. explain shows the price: usedDisk: true, two spills and 159 MB written to temporary files (compressed to 8.6 MB on disk, spilledDataStorageSize). Peak memory stayed just under the 100 MB cap (peakTrackedMemBytes ≈ 103.8 million bytes).
  4. The real fix is not more memory: sort on an indexed field so no blocking sort is needed, or $match/$limit first so far fewer documents reach the sort. The .filter() keeps only the sort stage of the explain output; the limit inside $sort is the coalesced $limit.
Predict the result: $limit before $sort
mongosh
db.products.aggregate([ { $limit: 3 }, { $sort: { price: -1 } }, { $project: { _id: 0, name: 1, price: 1 } } ])
Show answer
Output
[
  { name: 'MacBook Air', price: 1299 },
  { name: 'ThinkBook 14', price: 899 },
  { name: 'Pixel 10', price: 799 }
]
What’s happening
  1. $limit: 3 runs first and passes whichever three documents the collection scan produced first. Here that's natural (insertion) order: products 1, 2 and 3. That order isn't guaranteed either.
  2. $sort then orders only those three by price. The iPhone (1099) isn't in the result even though it's the second most expensive product, because it never got past the $limit.
  3. To get "the three most expensive products", $sort must come before $limit. Since stage order is the order of work, the optimiser won't swap them for you: it would change the answer.

#Expressions and $addFields

Inside stages like $project, $addFields, $group and $match: { $expr }, values are expressions: a small functional language evaluated once per document. An expression is one of:

  • a field path: "$price", "$items.qty", read from the current document.
  • a literal: 5, "text", or { $literal: ... } when it might be mistaken for something else.
  • an operator: { $multiply: ["$qty", "$unitPrice"] }, { $cond: { if, then, else } }, { $dateToString: { ... } }. Operators nest freely.
  • a variable: "$$ROOT" (the whole document), "$$NOW" (the current time), or one you name in $map, $filter or $let, read with two dollars ("$$it.qty").

$addFields (alias $set) adds or overwrites fields and keeps everything else, which is why it's usually more convenient than $project in the middle of a pipeline. Think of it as a spreadsheet: $addFields adds formula columns to the existing sheet, while $project produces a new sheet with only the columns you list.

mongosh
db.orders.aggregate([
  { $match: { customerId: 1 } },
  { $addFields: {
      itemCount: { $size: "$items" },
      total: { $sum: { $map: { input: "$items", as: "it",
                               in: { $multiply: ["$$it.qty", "$$it.unitPrice"] } } } },
      month: { $dateToString: { date: "$orderedAt", format: "%Y-%m" } }
  } },
  { $set: { shipping: { $cond: { if: { $gte: ["$total", 500] }, then: 0, else: 15 } } } },
  { $project: { items: 0 } }
])
Result
[
  {
    _id: 1,
    customerId: 1,
    status: 'shipped',
    orderedAt: ISODate('2025-02-01T10:00:00.000Z'),
    itemCount: 2,
    total: 957,
    month: '2025-02',
    shipping: 0
  },
  {
    _id: 2,
    customerId: 1,
    status: 'paid',
    orderedAt: ISODate('2025-03-15T12:30:00.000Z'),
    itemCount: 2,
    total: 88,
    month: '2025-03',
    shipping: 15
  }
]
What’s happening
  1. $match keeps Rohit's two orders. Each still has its items array, e.g. order 1: [{ productId: 1, qty: 1, unitPrice: 899 }, { productId: 7, qty: 2, unitPrice: 29 }].
  2. itemCount: { $size: "$items" } counts array elements → 2 for both orders.
  3. $map walks the items array, binding each element to the variable it, and returns a new array of line totals: [899, 58] for order 1. $sum of an array adds its elements → 957. Order 2 gives [39, 49] → 88. This is how you compute per-document totals without $unwind.
  4. $dateToString formats the date as "2025-02", the kind of key you'd group by for a monthly report.
  5. The $set stage can use total because it was created by the previous stage. Inside a single $addFields, the fields can't see each other: putting shipping in the first $addFields would read a total that doesn't exist yet. $cond returns 0 for the 957 order and 15 for the 88 order.
  6. $project: { items: 0 } is an exclusion projection, used here only to hide the array from the output.
AdvancedCorrecting my notes: $toDate needs a field path

My notes had the $toDate shortcut as birthDate: { $toDate: "dob.date" }. That's missing the $: without it, "dob.date" is a literal string, and MongoDB tries to parse the text dob.date as a date. Here people stores the date of birth as a string, the way JSON APIs (like randomuser.me) often deliver it:

mongosh
db.people.insertMany([
  { _id: 1, name: "Asha", dob: { date: "1993-07-20T03:44:25.870Z", age: 33 } },
  { _id: 2, name: "Ben",  dob: { date: "1988-11-02T10:00:00.000Z", age: 37 } }
])
db.people.aggregate([ { $project: { name: 1, birthDate: { $toDate: "dob.date" } } } ])
db.people.aggregate([ { $project: { name: 1, birthDate: { $toDate: "$dob.date" } } } ])
db.people.aggregate([ { $project: { name: 1, birthYear: { $year: { $toDate: "$dob.date" } }, kind: { $type: "$dob.date" } } } ])
Result
{ acknowledged: true, insertedIds: { '0': 1, '1': 2 } }
Uncaught MongoServerError[ConversionFailure]: Failed to optimize pipeline :: caused by :: Error parsing date string 'dob.date'; 0: passing a time zone identifier as part of the string is not allowed 'd'; 4: Double timezone specification 'd'
[
  {
    _id: 1,
    name: 'Asha',
    birthDate: ISODate('1993-07-20T03:44:25.870Z')
  },
  { _id: 2, name: 'Ben', birthDate: ISODate('1988-11-02T10:00:00.000Z') }
]
[
  { _id: 1, name: 'Asha', birthYear: 1993, kind: 'string' },
  { _id: 2, name: 'Ben', birthYear: 1988, kind: 'string' }
]
What’s happening
  1. With "dob.date" the error says "Failed to optimize pipeline": the expression has no field path, so it's a constant, and the optimiser tries to evaluate it once before reading any document. The text dob.date is not a date, so it fails immediately.
  2. With "$dob.date" each document's string is converted to a real BSON date.
  3. Once converted, date operators like $year work. $type confirms the stored field is still a 'string': the conversion only happens in the pipeline's output. To fix the data itself, run the same expression in an update pipeline (see Update pipelines).
  4. For untrusted input use $convert with onError (and onNull), which returns a fallback instead of failing the whole pipeline: { $convert: { input: "not a date", to: "date", onError: null } } gives null.
Advanced$switch, $ifNull and $filter

Three expressions you'll reach for constantly: multi-way branching, defaults for missing values, and filtering an array inside a document.

mongosh
db.products.aggregate([
  { $project: { _id: 0, name: 1, price: 1,
      band: { $switch: { branches: [
        { case: { $lt: ["$price", 50] }, then: "budget" },
        { case: { $lt: ["$price", 1000] }, then: "mid" }
      ], default: "premium" } } } }
])
db.customers.aggregate([
  { $match: { _id: { $in: [3, 4] } } },
  { $project: { _id: 0, name: 1, city: { $ifNull: ["$city", "unknown"] } } }
])
db.orders.aggregate([
  { $match: { _id: { $in: [1, 7] } } },
  { $project: { multi: { $filter: { input: "$items", as: "it", cond: { $gt: ["$$it.qty", 1] } } } } }
])
Result
[
  { name: 'ThinkBook 14', price: 899, band: 'mid' },
  { name: 'MacBook Air', price: 1299, band: 'premium' },
  { name: 'Pixel 10', price: 799, band: 'mid' },
  { name: 'iPhone 17', price: 1099, band: 'premium' },
  { name: 'SQL Performance Explained', price: 39, band: 'budget' },
  {
    name: 'Designing Data-Intensive Applications',
    price: 49,
    band: 'budget'
  },
  { name: 'USB-C Charger', price: 29, band: 'budget' },
  { name: 'Noise-cancelling Headphones', price: 249, band: 'mid' }
]
[
  { name: 'Liam', city: 'London' },
  { name: 'Sofia', city: 'unknown' }
]
[
  { _id: 1, multi: [ { productId: 7, qty: 2, unitPrice: 29 } ] },
  { _id: 7, multi: [ { productId: 5, qty: 3, unitPrice: 39 } ] }
]
What’s happening
  1. $switch tries each case in order and returns the first matching then, like SQL's CASE WHEN. The 249 headphones fail < 50, pass < 1000 → 'mid'. Nothing matches 1299, so default applies. Without a default, a non-matching document is a runtime error.
  2. $ifNull returns the first non-null argument: Sofia's city is null, so she gets 'unknown'. It treats a missing field the same as null.
  3. $filter returns the elements of items for which cond is true. $$it is the current element (two dollars because it's a variable, not a field). Order 1 keeps only the charger line (qty 2); order 7 keeps only the book line (qty 3).
  4. $map, $filter and $reduce are why aggregation can work on embedded arrays without $unwind, which keeps one document per order instead of one per line.
AdvancedPitfall: a missing $ or a typo fails differently
Predict the result: $addFields fields referring to each other
mongosh
db.orders.aggregate([
  { $match: { _id: 2 } },
  { $addFields: {
      total: { $sum: { $map: { input: "$items", in: { $multiply: ["$$this.qty", "$$this.unitPrice"] } } } },
      withTax: { $multiply: ["$total", 1.13] }
  } },
  { $project: { _id: 0, total: 1, withTax: 1 } }
])
Show answer

[ { total: 88, withTax: null } ].

What’s happening
  1. Every expression in one $addFields is evaluated against the document as it entered the stage.
  2. total is computed as 39 + 49 = 88 ($$this is the default variable name in $map when you don't give as).
  3. withTax reads "$total" from the incoming document, where total doesn't exist yet. A missing value in $multiply gives null.
  4. Fix: compute withTax in a second $addFields/$set stage, or inline the expression, or use $let to name the intermediate value once.

#$lookup: joining collections

$lookup brings documents from another collection into the current one. For each input document it finds the matching documents in the from collection and puts them, as an array, into a new field named by as. It's MongoDB's join, and it's a left outer join: an input document with no matches is kept, with an empty array.

Picture a librarian processing a stack of borrower cards. For each card, she walks to the loans cabinet, pulls every loan slip with that borrower's number, and staples copies of them to the card. A card with no loans still goes back on the pile, just with nothing stapled to it. The stapled bundle is always a list, even when it holds one slip, which is the point my notes make: "the new field will be an array".

The basic form matches one field on each side for equality: localField in the input document against foreignField in the from collection.

mongosh
db.customers.aggregate([
  { $match: { _id: { $in: [1, 6] } } },
  { $lookup: {
      from: "orders",
      localField: "_id",
      foreignField: "customerId",
      as: "customerOrders"
  } },
  { $project: { name: 1, "customerOrders._id": 1, "customerOrders.status": 1 } }
])
Result
[
  {
    _id: 1,
    name: 'Rohit',
    customerOrders: [ { _id: 1, status: 'shipped' }, { _id: 2, status: 'paid' } ]
  },
  { _id: 6, name: 'Zara', customerOrders: [] }
]
What’s happening
  1. $match keeps two customers: Rohit (_id 1), who has orders, and Zara (_id 6), who has none.
  2. For Rohit, $lookup searches orders for documents where customerId equals his _id (1), finds orders 1 and 2, and stores both full order documents in customerOrders.
  3. For Zara it finds nothing, and she is not dropped: she gets customerOrders: []. That's the left outer join behaviour; an inner join would need an extra step ($match: { customerOrders: { $ne: [] } }, or $unwind).
  4. The $project uses dotted paths into the array ("customerOrders._id") to keep only two fields of each joined order. Without it, every joined order would appear in full, items included.
  5. customerOrders is an array even for a single match. To get one sub-document per row, follow with $unwind.
AdvancedJoining on an array field

If localField is an array, $lookup matches any of its elements, which is exactly what you need to fetch the product for every line item of an order:

mongosh
db.orders.aggregate([
  { $match: { _id: 3 } },
  { $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "productDocs" } },
  { $project: { "items.productId": 1, "productDocs._id": 1, "productDocs.name": 1 } }
])
Result
[
  {
    _id: 3,
    items: [ { productId: 3 }, { productId: 8 } ],
    productDocs: [
      { _id: 3, name: 'Pixel 10' },
      { _id: 8, name: 'Noise-cancelling Headphones' }
    ]
  }
]
What’s happening
  1. "items.productId" on order 3 resolves to the array [3, 8].
  2. $lookup returns every product whose _id is in that array, the same as $in: [3, 8].
  3. The products come back as a separate array, not merged into the matching line items. To put each product next to its line, either $unwind: "$items" before the lookup (one lookup per line), or merge the two arrays afterwards with $map and $filter.
  4. A product ordered twice in the same order would still appear once in productDocs: it's a set of matching documents, not one per array element.
AdvancedCorrelated lookups with let and pipeline

When the join needs more than one equality (a filter, a range, a projection of the joined documents), use the pipeline form. let copies fields from the input document into variables, and the sub-pipeline runs against the from collection with those variables available:

mongosh
db.customers.aggregate([
  { $lookup: {
      from: "orders",
      let: { cid: "$_id" },
      pipeline: [
        { $match: { $expr: { $and: [
            { $eq: ["$customerId", "$$cid"] },
            { $ne: ["$status", "cancelled"] }
        ] } } },
        { $project: { _id: 1, status: 1 } }
      ],
      as: "liveOrders"
  } },
  { $project: { name: 1, liveOrders: 1, orderCount: { $size: "$liveOrders" } } }
])
Result
[
  {
    _id: 1,
    name: 'Rohit',
    liveOrders: [ { _id: 1, status: 'shipped' }, { _id: 2, status: 'paid' } ],
    orderCount: 2
  },
  {
    _id: 2,
    name: 'Maya',
    liveOrders: [ { _id: 3, status: 'shipped' }, { _id: 8, status: 'shipped' } ],
    orderCount: 2
  },
  {
    _id: 3,
    name: 'Liam',
    liveOrders: [ { _id: 5, status: 'shipped' } ],
    orderCount: 1
  },
  {
    _id: 4,
    name: 'Sofia',
    liveOrders: [ { _id: 6, status: 'pending' } ],
    orderCount: 1
  },
  {
    _id: 5,
    name: 'Noah',
    liveOrders: [ { _id: 7, status: 'paid' } ],
    orderCount: 1
  },
  { _id: 6, name: 'Zara', liveOrders: [], orderCount: 0 }
]
What’s happening
  1. For each customer, let binds $$cid to that customer's _id. Inside the sub-pipeline, $customerId (one dollar) means a field of the order, and $$cid (two dollars) means the variable from the customer.
  2. Variables can only be used inside $expr, which is why the $match is written in expression form. The second condition drops Liam's cancelled order 4, so Liam shows 1 live order instead of 2.
  3. The sub-pipeline's $project trims each joined order to _id and status before it's attached, which keeps the result small.
  4. $size turns the joined array into a count. Zara still appears with 0: this is "customers and their order counts, including those with none", the classic left-join report.
  5. Since MongoDB 5.0 you can combine both styles: localField/foreignField for the equality, plus a pipeline for the extra filter, e.g. { from: "orders", localField: "_id", foreignField: "customerId", pipeline: [{ $match: { status: "shipped" } }], as: "shipped" }. That's shorter and easier for the server to optimise.
AdvancedIndex the foreignField

For each input document, $lookup has to find matches in the other collection. explain shows which join strategy MongoDB 9.0 picked; adding an index on orders.customerId changes it:

mongosh
const p = [ { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "o" } } ]
JSON.stringify(db.customers.explain().aggregate(p)).match(/"stage":"EQ_LOOKUP"|"strategy":"\w+"/g)
db.orders.createIndex({ customerId: 1 })
JSON.stringify(db.customers.explain().aggregate(p)).match(/"stage":"EQ_LOOKUP"|"strategy":"\w+"/g)
Result
[ '"stage":"EQ_LOOKUP"', '"strategy":"HashJoin"' ]
customerId_1
[ '"stage":"EQ_LOOKUP"', '"strategy":"IndexedLoopJoin"' ]
What’s happening
  1. EQ_LOOKUP means the lookup runs in the slot-based execution engine (6.0+), which handles simple equality lookups.
  2. Without an index, the engine builds a hash table from the orders collection and probes it once per customer (HashJoin). That's fine when the foreign collection is small enough to hash in memory; otherwise it falls back to a nested loop that scans orders once per customer.
  3. With an index on the foreignField, it switches to IndexedLoopJoin: one index lookup per customer. For a large foreign collection this is the difference between a fast join and a slow one, so index the foreignField of every $lookup you run often.
  4. The pipeline form with let + $expr and lookups against views or sharded collections run in the classic engine instead; they can still use an index on the joined field for $eq comparisons.
AdvancedRecursive joins with $graphLookup

$graphLookup repeats a lookup recursively, following a reference from document to document: org charts, category trees, "friends of friends". It's MongoDB's version of a recursive CTE. Here it walks up Dev's management chain:

mongosh
db.employees.aggregate([
  { $match: { name: "Dev" } },
  { $graphLookup: {
      from: "employees",
      startWith: "$managerId",
      connectFromField: "managerId",
      connectToField: "_id",
      as: "chain",
      depthField: "level"
  } },
  { $project: { _id: 0, name: 1, chain: { $sortArray: {
      input: { $map: { input: "$chain", as: "m", in: { name: "$$m.name", level: "$$m.level" } } },
      sortBy: { level: 1 } } } } }
])
Result
[
  {
    name: 'Dev',
    chain: [
      { name: 'Ben', level: Long('0') },
      { name: 'Asha', level: Long('1') }
    ]
  }
]
What’s happening
  1. startWith: "$managerId" starts from Dev's manager id, 2.
  2. It finds the employee whose _id (connectToField) is 2: Ben, at depth 0. Then it takes Ben's managerId (connectFromField), 1, and finds Asha at depth 1. Asha's managerId is null, so the search stops.
  3. The results arrive in no guaranteed order, so $sortArray (5.2+) sorts them by level. Use maxDepth to cap the recursion and index connectToField, because each level is another lookup.
AdvancedOld and new: what changed in $lookup
  • MongoDB 3.2 introduced $lookup with localField/foreignField only.
  • 3.6 added the let + pipeline form for joins on more than one condition.
  • 5.0 allowed localField/foreignField and pipeline together (the concise correlated form).
  • 5.1 allowed the from collection to be sharded; before that it had to be unsharded.
  • 6.0 moved equality lookups into the slot-based engine (EQ_LOOKUP), with hash and indexed-loop strategies.
Predict the result: is $lookup an inner join?

How many documents does this return, and what does Zara's look like?

mongosh
db.customers.aggregate([
  { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } },
  { $project: { name: 1, n: { $size: "$orders" } } }
])
Show answer

6 documents, one per customer, and Zara's is { _id: 6, name: 'Zara', n: 0 }.

What’s happening
  1. $lookup never removes or duplicates input documents: 6 customers in, 6 out.
  2. Each gets an orders array: Rohit 2, Maya 2, Liam 2 (including the cancelled one, because this lookup doesn't filter), Sofia 1, Noah 1, Zara none.
  3. Zara's array is [], so $size gives 0. That's the left outer join behaviour my notes point out.
  4. The join only turns into an inner join if you add a stage that drops the empty arrays, which $unwind does by default. That's the trap the next topic is about.

#$unwind

$unwind takes a document with an array field and outputs one copy of the document per array element, with the array field replaced by that single element. In my notes: it "will separate arrays into individual elements". It's how you get from "an order with its lines" to "one row per order line", which is the shape SQL works in and the shape $group needs when you want to aggregate across array elements of different documents.

Think of a sheet of postage stamps being cut apart: one sheet in, many stamps out, and each stamp still carries the sheet's printing on its back (all the document's other fields are copied onto every output). That copying is the cost: unwinding a 100-element array turns one document into 100.

The most common use is straight after $lookup, because $lookup always produces an array. This is the users/orders pipeline from my notes on the sample data:

mongosh
db.customers.aggregate([
  { $match: { _id: { $in: [1, 6] } } },
  { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "userOrders" } },
  { $unwind: "$userOrders" },
  { $project: { name: 1, orderId: "$userOrders._id", status: "$userOrders.status" } }
])
Result
[
  { _id: 1, name: 'Rohit', orderId: 1, status: 'shipped' },
  { _id: 1, name: 'Rohit', orderId: 2, status: 'paid' }
]
What’s happening
  1. After $lookup, Rohit's document has userOrders: [order 1, order 2] and Zara's has userOrders: [] (from the previous topic).
  2. $unwind: "$userOrders" turns Rohit's single document into two: the first with userOrders set to order 1 (as an embedded document, no longer an array), the second with order 2. _id and name are copied onto both, which is why _id: 1 now appears twice.
  3. Zara's array has no elements, so $unwind produces no documents for her. She disappears from the result.
  4. That's exactly the problem my notes flag: by default $unwind removes documents whose array is empty, missing or null, which silently turns the left outer join into an inner join. The fix is below.
  5. The final $project lifts fields out of the now-single sub-document ("$userOrders._id") to get flat, SQL-like rows.
AdvancedpreserveNullAndEmptyArrays keeps the unmatched documents

The object form of $unwind takes options. preserveNullAndEmptyArrays: true outputs the document once, without the field, when the array is empty, null or missing:

mongosh
db.customers.aggregate([
  { $match: { _id: { $in: [1, 6] } } },
  { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "userOrders" } },
  { $unwind: { path: "$userOrders", preserveNullAndEmptyArrays: true } },
  { $project: { name: 1, orderId: "$userOrders._id", status: "$userOrders.status" } }
])
Result
[
  { _id: 1, name: 'Rohit', orderId: 1, status: 'shipped' },
  { _id: 1, name: 'Rohit', orderId: 2, status: 'paid' },
  { _id: 6, name: 'Zara' }
]
What’s happening
  1. Rohit is unwound exactly as before: two documents.
  2. Zara's empty userOrders would normally produce nothing. With preserveNullAndEmptyArrays: true she's output once, and userOrders is removed rather than set to an empty array.
  3. In the $project, "$userOrders._id" and "$userOrders.status" point at a missing field, so orderId and status are simply absent from her document: the MongoDB equivalent of the NULL columns a SQL LEFT JOIN gives you.
  4. Interview phrasing: "$lookup is a left outer join, but a plain $unwind after it makes it an inner join; preserveNullAndEmptyArrays keeps it a left join."
AdvancedUnwinding embedded line items

$unwind isn't only for joins. Unwinding an order's own items array gives one document per line, and includeArrayIndex records each element's position:

mongosh
db.orders.aggregate([
  { $match: { _id: 1 } },
  { $unwind: { path: "$items", includeArrayIndex: "line" } },
  { $project: { customerId: 1, line: 1, items: 1 } }
])
Result
[
  {
    _id: 1,
    customerId: 1,
    items: { productId: 1, qty: 1, unitPrice: 899 },
    line: Long('0')
  },
  {
    _id: 1,
    customerId: 1,
    items: { productId: 7, qty: 2, unitPrice: 29 },
    line: Long('1')
  }
]
What’s happening
  1. Order 1 has two line items, so it becomes two documents, both still with _id: 1. The _id is no longer unique in the stream; that's normal inside a pipeline, but it would fail if you wrote these documents to a collection with $out.
  2. items now holds a single sub-document in each copy, not an array.
  3. line is the element's array index (0, 1), stored as a 64-bit integer. Handy when the position means something, e.g. the first line of an order.
AdvancedRevenue by category: $unwind, $lookup and $group together

A real report combines the stages: unwind the lines, join each line to its product to get the category, then group. This is the "multiply 2 columns and sum them, grouped by category" example from my notes, done on actual sales instead of stock:

mongosh
db.orders.aggregate([
  { $match: { status: { $ne: "cancelled" } } },
  { $unwind: "$items" },
  { $lookup: { from: "products", localField: "items.productId", foreignField: "_id", as: "product" } },
  { $unwind: "$product" },
  { $group: {
      _id: "$product.category",
      unitsSold: { $sum: "$items.qty" },
      revenue: { $sum: { $multiply: ["$items.qty", "$items.unitPrice"] } }
  } },
  { $sort: { revenue: -1 } }
])
Result
[
  { _id: 'Laptops', unitsSold: 2, revenue: 1798 },
  { _id: 'Phones', unitsSold: 1, revenue: 799 },
  { _id: 'Accessories', unitsSold: 9, revenue: 701 },
  { _id: 'Books', unitsSold: 7, revenue: 303 }
]
What’s happening
  1. $match drops cancelled order 4 (the MacBook), so 7 orders continue.
  2. $unwind: "$items" turns those 7 orders into 12 line documents.
  3. $lookup attaches the product for each line as a one-element array; the second $unwind turns it into a plain sub-document so "$product.category" resolves to a string. (The optimiser actually merges a $lookup + $unwind pair into one stage, so no intermediate array is built.)
  4. $group by category sums quantities and qty × unitPrice per line. Accessories: 58 + 249 + 29 + 249 + 116 = 701 over 2 + 1 + 1 + 1 + 4 = 9 units.
  5. Note the price comes from the line item (unitPrice at the time of sale), not from the product's current price. Embedding the price paid in the order is deliberate: it's a historical fact, not a reference.
AdvancedWhat $unwind does with odd values

How $unwind treats every kind of value, on a throwaway collection u:

mongosh
db.u.insertMany([ { _id: 1, tags: ["a", "b"] }, { _id: 2, tags: [] }, { _id: 3, tags: null },
                  { _id: 4 }, { _id: 5, tags: "solo" } ])
db.u.aggregate([ { $unwind: "$tags" } ])
db.u.aggregate([ { $unwind: { path: "$tags", preserveNullAndEmptyArrays: true } } ])
Result
{
  acknowledged: true,
  insertedIds: { '0': 1, '1': 2, '2': 3, '3': 4, '4': 5 }
}
[
  { _id: 1, tags: 'a' },
  { _id: 1, tags: 'b' },
  { _id: 5, tags: 'solo' }
]
[
  { _id: 1, tags: 'a' },
  { _id: 1, tags: 'b' },
  { _id: 2 },
  { _id: 3, tags: null },
  { _id: 4 },
  { _id: 5, tags: 'solo' }
]
What’s happening
  1. A two-element array produces two documents.
  2. An empty array (2), null (3) and a missing field (4) all produce nothing by default.
  3. A non-array value (5) is treated as a one-element array and passes through unchanged (since MongoDB 3.2; older versions raised an error).
  4. With preserveNullAndEmptyArrays: true, all three "nothing" cases come back: the empty array becomes a missing field, while null stays null.
AdvancedPitfall: two unwinds multiply
Predict the result: counting line items
mongosh
db.orders.aggregate([ { $unwind: "$items" }, { $count: "lines" } ])
Show answer

[ { lines: 13 } ].

What’s happening
  1. The 8 orders have 2, 2, 2, 1, 2, 1, 2, 1 items.
  2. $unwind emits one document per element: 2 + 2 + 2 + 1 + 2 + 1 + 2 + 1 = 13.
  3. $count counts the documents reaching it, so the answer is the number of line items, not orders.
  4. The cheaper way to get the same number without multiplying documents: { $group: { _id: null, lines: { $sum: { $size: "$items" } } } }.

#$bucket

$bucket groups documents into ranges of a value instead of exact values: price bands, age groups, salary brackets, histograms. You give it the field to bucket by (groupBy), a sorted list of boundaries, an optional default bucket for anything outside them, and optional output accumulators, exactly like $group's.

Think of coin-sorting trays: one big slot for 0–49, one for 50–499, one for 500–999, and an overflow tray for anything else. Each boundary pair defines a slot whose lower edge is inclusive and upper edge exclusive, so a value sitting exactly on a boundary goes into the higher slot. My notes ask for use cases: e-commerce price filters ("Under $50 (3)"), age demographics, response-time histograms, salary bands in HR reports, and any "how is this value distributed?" question.

mongosh
db.products.aggregate([
  { $bucket: {
      groupBy: "$price",
      boundaries: [0, 50, 500, 1000],
      default: "1000+",
      output: { count: { $sum: 1 }, names: { $push: "$name" } }
  } }
])
Result
[
  {
    _id: 0,
    count: 3,
    names: [
      'SQL Performance Explained',
      'Designing Data-Intensive Applications',
      'USB-C Charger'
    ]
  },
  { _id: 50, count: 1, names: [ 'Noise-cancelling Headphones' ] },
  { _id: 500, count: 2, names: [ 'ThinkBook 14', 'Pixel 10' ] },
  { _id: '1000+', count: 2, names: [ 'MacBook Air', 'iPhone 17' ] }
]
What’s happening
  1. Four boundaries make three ranges: [0, 50), [50, 500) and [500, 1000). Each output document's _id is the range's lower boundary.
  2. Prices 39, 49 and 29 fall in [0, 50). 49 is in, because 50 is the exclusive upper edge.
  3. 249 lands in [50, 500); 899 and 799 in [500, 1000).
  4. 1299 and 1099 are outside every range, so they go to the default bucket, whose _id is the value you gave it, '1000+'.
  5. output works like $group accumulators: count and names per bucket. Without output, you'd get just { _id, count }.
  6. Empty ranges are not output. If no product cost 50 to 499, that bucket would be missing, not count: 0. Fill the gaps in application code if a chart needs them.
AdvancedEdge case: a value outside the boundaries with no default

Without a default, a single out-of-range value fails the whole pipeline:

mongosh
db.products.aggregate([ { $bucket: { groupBy: "$price", boundaries: [0, 50, 500, 1000] } } ])
Result
Uncaught MongoServerError[Location7158303]: Executor error during aggregate command on namespace: ch19.products :: caused by :: $switch could not find a matching branch for an input, and no default was specified.
What’s happening
  1. The MacBook (1299) and iPhone (1099) don't fit any range and there's nowhere else to put them.
  2. The error mentions $switch, not $bucket: internally the server rewrites $bucket into a $group whose _id is a $switch over the ranges. It's a nice illustration that $bucket is shorthand for $group plus a $switch.
  3. Always set default unless you're certain of the data's range. The same applies to documents where the field is missing or isn't a number.
AdvancedAge bands on 100,000 users

On a realistic data set, $bucket is how you build a demographic breakdown in one pass. Here it runs over 100,000 generated users:

mongosh
db.users.aggregate([
  { $bucket: {
      groupBy: "$dob.age",
      boundaries: [18, 25, 35, 45, 55, 65, 81],
      output: { users: { $sum: 1 }, avgScore: { $avg: "$score" } }
  } }
])
Result
[
  { _id: 18, users: 11153, avgScore: 497.8794943064646 },
  { _id: 25, users: 15977, avgScore: 494.98710646554423 },
  { _id: 35, users: 15891, avgScore: 497.40702284311874 },
  { _id: 45, users: 15764, avgScore: 497.4738645013956 },
  { _id: 55, users: 15782, avgScore: 500.5210999873273 },
  { _id: 65, users: 25433, avgScore: 499.12924153658633 }
]
What’s happening
  1. Ages run from 18 to 80, so the last boundary is 81 (exclusive) and no default is needed. The bands are unequal on purpose: 18–24, then decades, then 65–80.
  2. The 65+ band has the most users simply because it's 16 years wide.
  3. $avg gives full double precision; round it in a later $project ({ $round: ["$avgScore", 1] }) for display.
Advanced$bucketAuto picks the boundaries for you

When you don't know the distribution, $bucketAuto takes the number of buckets and chooses boundaries that spread documents as evenly as possible:

mongosh
db.users.aggregate([ { $bucketAuto: { groupBy: "$score", buckets: 4 } } ])
db.employees.aggregate([
  { $bucketAuto: { groupBy: "$salary", buckets: 3, output: { n: { $sum: 1 }, who: { $push: "$name" } } } }
])
Result
[
  { _id: { min: 0, max: 249 }, count: 25038 },
  { _id: { min: 249, max: 498 }, count: 25078 },
  { _id: { min: 498, max: 750 }, count: 25010 },
  { _id: { min: 750, max: 999 }, count: 24874 }
]
[
  {
    _id: { min: 72000, max: 95000 },
    n: 3,
    who: [ 'Hiro', 'Isla', 'Jonas' ]
  },
  {
    _id: { min: 95000, max: 130000 },
    n: 3,
    who: [ 'Dev', 'Farid', 'Grace' ]
  },
  {
    _id: { min: 130000, max: 185000 },
    n: 4,
    who: [ 'Asha', 'Ben', 'Chloe', 'Elena' ]
  }
]
What’s happening
  1. _id is now a { min, max } range chosen by the server, with min inclusive and max exclusive (except the last bucket, whose max is the largest value).
  2. The four score quartiles hold roughly 25,000 users each: that's the goal of $bucketAuto.
  3. With 10 employees and 3 buckets, the split can't be perfectly even, and documents with the same value are never split across buckets. That's why the last bucket has 4.
  4. Use granularity (e.g. "R10" or "1-2-5") to make it pick round-number boundaries suitable for a UI.
Predict the result: a value on a boundary

With boundaries: [0, 50, 500, 1000] and default: "other", which bucket does a product priced exactly 500 go into, and one priced exactly 1000?

Show answer

500 goes into the 500 bucket; 1000 goes into "other".

What’s happening
  1. Each range includes its lower boundary and excludes its upper one: [500, 1000) contains 500.
  2. 1000 is the upper boundary of the last range, so it's excluded from it, and there's no range starting at 1000.
  3. So 1000 falls to the default bucket. If you want 1000 included, make the last boundary 1001 (for integers) or add another boundary.

#$facet

$facet runs several sub-pipelines over the same input documents in a single stage, and returns one document with one array field per sub-pipeline. My notes call it "multiple pipelines in parallel". Conceptually that's right: every facet sees the same input. In practice the server feeds the documents to each facet in turn within one stage; it isn't running them on separate threads.

The classic use is a search results page. One query has to produce the matching products for page 1, the total count for "Showing 1–20 of 347", the category counts for the sidebar, and the price-range counts for the filter. Without $facet that's four round trips that each filter the same data again. With $facet it's one pipeline: a $match up front for the search, then a facet per box on the page. Picture a photo being sent to four editors at once, each cropping it differently, with the results stapled into one envelope.

mongosh
db.products.aggregate([
  { $match: { stock: { $gt: 0 } } },
  { $facet: {
      byCategory: [ { $sortByCount: "$category" } ],
      priceBands: [ { $bucket: { groupBy: "$price", boundaries: [0, 100, 1000],
                                 default: "1000+", output: { n: { $sum: 1 } } } } ],
      page1: [ { $sort: { price: -1 } }, { $limit: 2 }, { $project: { _id: 0, name: 1, price: 1 } } ],
      total: [ { $count: "n" } ]
  } }
])
Result
[
  {
    byCategory: [
      { _id: 'Accessories', count: 2 },
      { _id: 'Books', count: 2 },
      { _id: 'Laptops', count: 2 },
      { _id: 'Phones', count: 1 }
    ],
    priceBands: [ { _id: 0, n: 3 }, { _id: 100, n: 3 }, { _id: '1000+', n: 1 } ],
    page1: [
      { name: 'MacBook Air', price: 1299 },
      { name: 'ThinkBook 14', price: 899 }
    ],
    total: [ { n: 7 } ]
  }
]
What’s happening
  1. $match runs once, before the facet, and removes the out-of-stock iPhone: 7 products continue. This stage can use an index; the facets themselves can't (see below).
  2. Each of the four sub-pipelines receives the same 7 documents, independently. byCategory counting them doesn't consume them for page1.
  3. byCategory uses $sortByCount: Phones has 1 because the iPhone was filtered out. priceBands reuses $bucket from the previous topic.
  4. page1 sorts and limits to the two most expensive in-stock items, the page of results the user sees.
  5. total is an array holding one document, [ { n: 7 } ], because every facet's output is an array, even a single count. In my notes this was flagged "READ CAREFULLY", and this is the part to read carefully: the whole result is one document, every field is an array, and a facet with no results gives [], not a missing field.
AdvancedFlattening the facet result for an API

Clients usually want total: 7, not total: [ { n: 7 } ]. Reshape after the facet:

mongosh
db.products.aggregate([
  { $facet: {
      page: [ { $sort: { _id: 1 } }, { $skip: 2 }, { $limit: 2 }, { $project: { name: 1 } } ],
      total: [ { $count: "n" } ]
  } },
  { $project: { page: 1, total: { $ifNull: [ { $arrayElemAt: ["$total.n", 0] }, 0 ] } } }
])
Result
[
  {
    page: [ { _id: 3, name: 'Pixel 10' }, { _id: 4, name: 'iPhone 17' } ],
    total: 8
  }
]
What’s happening
  1. page is page 2 of a 2-per-page listing, ordered by _id; total counts all 8 products.
  2. "$total.n" reads n from each element of the total array, giving [8], and $arrayElemAt: [..., 0] takes the first.
  3. When nothing matches, $count outputs no document at all, so total would be [] and $arrayElemAt would return nothing. $ifNull turns that into 0.
AdvancedFacets can't use indexes, but the stage before can

Each sub-pipeline receives documents from the stage before $facet, so a $match inside a facet can't use an index. Compare a count of Toronto users done inside the facet with one done before it, on 100,000 users with an index on city:

mongosh
db.users.createIndex({ city: 1 })
// summarises explain("executionStats") output: plan stages, docs and keys examined, time
plan([ { $facet: { toronto: [ { $match: { city: "Toronto" } }, { $count: "n" } ] } } ])
plan([ { $match: { city: "Toronto" } }, { $facet: { toronto: [ { $count: "n" } ] } } ])
db.users.aggregate([ { $match: { city: "Toronto" } }, { $facet: { toronto: [ { $count: "n" } ] } } ])
Result
city_1
{
  stages: [ '"stage":"PROJECTION_SIMPLE"', '"stage":"COLLSCAN"' ],
  docsExamined: [ '"totalDocsExamined":100000' ],
  keysExamined: [ '"totalKeysExamined":0' ],
  ms: [ '"executionTimeMillis":56' ]
}
{
  stages: [ '"stage":"COUNT_SCAN"' ],
  docsExamined: [ '"totalDocsExamined":0' ],
  keysExamined: [ '"totalKeysExamined":18775' ],
  ms: [ '"executionTimeMillis":3' ]
}
[ { toronto: [ { n: 18774 } ] } ]
What’s happening
  1. With the $match inside the facet, the server must feed every document into the facet: a COLLSCAN of all 100,000 (fetching only the city field), 56 ms.
  2. With the $match before the facet, it's an ordinary query on city, and since only a count is needed, the server answers from the index alone (COUNT_SCAN): 0 documents read, 18,775 index keys (one more than the 18,774 matches, to find the end of the range), 3 ms.
  3. The rule: put shared filters, and anything that can use an index, before $facet. Inside facets, keep the work to grouping, counting and slicing the already-reduced set.
  4. plan() is a small helper written for this page that runs explain("executionStats") and pulls out those fields; the full explain output is long.
AdvancedThe 16 MB limit applies to the one output document

Because $facet produces a single document, everything it returns must fit in 16 MB. Putting a whole collection into a facet shows the limit:

mongosh
db.users.aggregate([ { $facet: { everything: [] } }, { $project: { bytes: { $bsonSize: "$$ROOT" } } } ])
db.users.aggregate([ { $facet: { everything: [] } } ]).toArray().length
db.users.aggregate([ { $facet: { inner: [ { $facet: { x: [] } } ] } } ])
Result
[ { bytes: 26563676 } ]
Uncaught MongoServerError[BSONObjectTooLarge]: Executor error during aggregate command on namespace: ch19.users :: caused by :: Serializing Document failed :: caused by :: Size 26563676 exceeds maximum 16793600
Uncaught MongoServerError[Location40600]: $facet is not allowed to be used within a $facet stage
What’s happening
  1. An empty sub-pipeline passes all 100,000 users through, so the facet document is 26.6 MB. Inside the pipeline that's allowed: the first query only measures it with $bsonSize and returns a tiny document.
  2. Returning it to the client fails with BSONObjectTooLarge: 26,563,676 bytes is more than the 16,793,600-byte maximum for a document on the wire (16 MiB plus a little headroom for command overhead).
  3. So always $limit the facets that return documents, and return counts rather than lists where you can.
  4. Some stages are not allowed inside a facet: another $facet, $out, $merge, $geoNear, $indexStats, $collStats, and a few others. The error for a nested facet is shown.
Predict the result: an empty facet
mongosh
db.products.aggregate([
  { $match: { category: "Tablets" } },
  { $facet: { items: [ { $limit: 5 } ], total: [ { $count: "n" } ] } }
])
Show answer

[ { items: [], total: [] } ]: one document with two empty arrays.

What’s happening
  1. No product has category "Tablets", so zero documents reach $facet.
  2. $facet still outputs exactly one document. It always does, which makes the response shape predictable for the client.
  3. items is [] because $limit passed nothing. total is [] too, not [ { n: 0 } ]: $count emits nothing when it counted nothing.
  4. That's why the flattening example above wraps the count in $ifNull: [..., 0].

#Pipeline performance

A pipeline is fast when few documents enter it and no stage has to hold everything at once. The cost of a pipeline is roughly the number of documents each stage touches, so the two biggest levers are the ones in my notes: filter early, so later stages see less, and make the filter use an index, so the first stage doesn't have to read the whole collection.

My notes' two rules were: 1, use $match early; 2, use $project early. The first is right and the most important advice on this page. The second is outdated: the server already works out which fields the pipeline needs (dependency tracking) and fetches only those, and the MongoDB docs say an early $project "is unlikely to improve performance". Put $project at the end to shape the output. What matters more than $project is the start of the pipeline: a $match and then a $sort at the beginning are executed like a find() with a sort, so they can use an index, while anything after $group, $unwind or $project can't.

Think of the pipeline as two zones. The query layer at the front ($match, $sort, $limit, plus whatever the optimiser can move into it) can use indexes. Everything after the first stage that changes document shape or count runs in the pipeline layer, which only ever sees a stream. Your job is to get as much work as possible into the first zone. Here is the same report, top 3 Toronto users by score, written badly and well, on 100,000 users with an index on { city: 1, score: -1 }:

mongosh
db.users.createIndex({ city: 1, score: -1 })
const band = { $addFields: { ageBand: { $multiply: [ { $floor: { $divide: ["$dob.age", 10] } }, 10 ] } } }
const proj = { $project: { _id: 0, name: 1, score: 1, ageBand: 1 } }
// summary() runs explain("executionStats") and picks out the plan and counters
summary([ band, { $sort: { score: -1 } }, { $match: { city: "Toronto" } }, { $limit: 3 }, proj ])
summary([ { $match: { city: "Toronto" } }, { $sort: { score: -1 } }, { $limit: 3 }, band, proj ])
Result
city_1_score_-1
{
  cursorPlan: 'PROJECTION_DEFAULT <- FETCH <- IXSCAN',
  laterStages: [ '$addFields', '$sort', '$project' ],
  keysExamined: 18774,
  docsExamined: 18774,
  docsFromCursor: 18774,
  ms: 26
}
{
  cursorPlan: 'LIMIT <- PROJECTION_DEFAULT <- FETCH <- IXSCAN',
  laterStages: [ '$addFields', '$project' ],
  keysExamined: 3,
  docsExamined: 3,
  docsFromCursor: 3,
  ms: 0
}
What’s happening
  1. In the first pipeline, the $match is written third. The optimiser moved it to the front anyway, because city isn't computed by $addFields, so it does use the index: IXSCAN on city_1_score_-1. Without that move it would have scanned all 100,000 users.
  2. But the $sort stayed behind the $addFields, in the pipeline layer (laterStages still lists $sort). So the index only narrowed the search to Toronto: all 18,774 Toronto users were fetched, decorated with ageBand, and sorted in memory to keep 3. 26 ms.
  3. In the second pipeline, $match + $sort + $limit come first, so the whole thing runs in the query layer: the index is already ordered by score descending within each city, so the server reads the first 3 index entries for Toronto and stops (LIMIT, 3 keys, 3 documents). Under 1 ms.
  4. $addFields and $project now run on 3 documents instead of 18,774. Same result, about 6,000 times fewer documents touched.
  5. PROJECTION_DEFAULT in both plans is the dependency tracking from above: the server fetched only name, score and dob.age without being told. That's why an early $project adds nothing.
AdvancedThe same query with a weaker index, or none

How much the index matters for the "good" pipeline:

mongosh
db.users.dropIndex("city_1_score_-1").ok
summary([ { $match: { city: "Toronto" } }, { $sort: { score: -1 } }, { $limit: 3 },
          { $project: { _id: 0, name: 1, score: 1 } } ])
db.users.createIndex({ city: 1 })
summary([ { $match: { city: "Toronto" } }, { $sort: { score: -1 } }, { $limit: 3 },
          { $project: { _id: 0, name: 1, score: 1 } } ])
Result
1
{
  cursorPlan: 'PROJECTION_SIMPLE <- SORT <- COLLSCAN',
  laterStages: [],
  keysExamined: 0,
  docsExamined: 100000,
  docsFromCursor: 3,
  ms: 17
}
city_1
{
  cursorPlan: 'PROJECTION_SIMPLE <- SORT <- FETCH <- IXSCAN',
  laterStages: [],
  keysExamined: 18774,
  docsExamined: 18774,
  docsFromCursor: 3,
  ms: 9
}
What’s happening
  1. With no index, the whole pipeline is pushed into the query layer (no laterStages), but the query layer has to COLLSCAN all 100,000 documents and do a top-3 SORT in memory: 17 ms.
  2. With an index on city only, the scan is narrowed to 18,774 Toronto users, but they still have to be fetched and sorted: 9 ms.
  3. With the compound { city: 1, score: -1 } index from the main example, it's 3 keys, 3 documents and under a millisecond. The difference grows with the data: the compound index stays at 3 documents whether there are 20,000 or 20 million Toronto users. Index design is the subject of the next chapter (The ESR rule explains the field order).
AdvancedWhat the optimiser rewrites for you

The server rewrites pipelines before running them. You can see the result in explain(). The rewrites in the MongoDB 9.0 docs include:

  • $match moves before $project/$addFields/$set/$unset when it doesn't use a computed field, and before $sort.
  • $sort + $limit coalesce into a top-k sort that keeps only n documents in memory. With a $skip in between, the limit becomes skip + limit.
  • Adjacent $match stages merge with $and; adjacent $limits keep the smaller value; adjacent $skips add up.
  • $lookup + $unwind (+ a $match on the joined fields) coalesce into one $lookup, so the intermediate array is never built.
  • New in 9.0: a computed field used only by a later $match can move, together with that $match, ahead of expensive stages like $lookup that it doesn't depend on.

Here's the $lookup coalescence in this sandbox:

mongosh
db.customers.explain().aggregate([
  { $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "o" } },
  { $unwind: "$o" },
  { $match: { "o.status": "shipped" } }
]).stages.slice(1)
Result
[
  {
    '$lookup': {
      from: 'orders',
      as: 'o',
      localField: '_id',
      foreignField: 'customerId',
      let: {},
      pipeline: [
        {
          '$match': {
            status: {
              '$eq': 'shipped'
            }
          }
        }
      ],
      unwinding: {
        preserveNullAndEmptyArrays: false
      }
    }
  }
]
What’s happening
  1. Three stages were written; explain shows one $lookup after the initial cursor.
  2. The $unwind became unwinding: { preserveNullAndEmptyArrays: false } inside the lookup, so each match is emitted as its own document directly, without building the o array.
  3. The $match on o.status was pushed into the lookup as a sub-pipeline filter on status, so non-shipped orders are never joined at all.
  4. The docs warn these rewrites can change between releases. When performance matters, check explain rather than assuming.
AdvancedA practical checklist
  • $match first, on indexed fields, and $sort right after it if you need ordering, so both run in the query layer.
  • $limit as early as the logic allows. $sort + $limit together is a cheap top-k; $sort alone over a big set is a blocking, memory-limited sort.
  • Avoid $unwind when an array expression will do. $size, $map, $filter and $reduce work inside the document without multiplying it.
  • Index $lookup's foreignField, and filter the joined collection inside the lookup rather than after it.
  • Put shared filters before $facet, because sub-pipelines can't use indexes.
  • Mind the 100 MB per-stage memory limit ($group, $sort, $bucket, $setWindowFields). Since 6.0 they spill to disk by default; spilling works, but it's slow. MongoDB 9.0 also caps the memory of a single query operation (1 GB or 20% of RAM, whichever is greater).
  • Pre-compute expensive reports with $merge into a summary collection on a schedule (next topic), instead of running them on every page view.
  • Use explain("executionStats") on the aggregate to check for IXSCAN, docsExamined close to nReturned, and usedDisk.
Predict the result: will this $match move?
mongosh
db.users.aggregate([
  { $addFields: { ageBand: { $multiply: [ { $floor: { $divide: ["$dob.age", 10] } }, 10 ] } } },
  { $match: { ageBand: 30, city: "Toronto" } }
])
Show answer

The optimiser splits the $match: { city: "Toronto" } moves before $addFields (and can use an index on city); { ageBand: 30 } stays after it.

What’s happening
  1. The optimiser looks at each condition of the $match separately.
  2. city is an original field, not touched by the $addFields, so that condition can run first, in the query layer.
  3. ageBand only exists after the $addFields has computed it, so that part of the filter must stay behind it. explain() shows exactly this: the cursor filter is { city: { '$eq': 'Toronto' } } (an IXSCAN on the city index), followed by $addFields and then $match: { ageBand: { '$eq': 30 } }.
  4. If you need the age filter to use an index too, write it against the original field: { "dob.age": { $gte: 30, $lt: 40 } }. Filters on computed values can never use an index.

#Aggregation vs map-reduce

Added

Before the aggregation pipeline matured, MongoDB's tool for custom data processing was map-reduce: you write a JavaScript map function that calls emit(key, value) for each document, and a reduce function that combines all the values emitted for one key. The server runs both functions in its embedded JavaScript engine. It's the same idea as Hadoop's MapReduce: transform each record into key/value pairs, then fold the values per key.

Map-reduce is deprecated since MongoDB 5.0. The docs say to use the aggregation pipeline instead, because it's faster and easier to use. The difference is in how the work runs: pipeline stages are native C++ operators that the optimiser understands, can push into indexes and can run in the slot-based engine, while map-reduce calls your JavaScript once per document and the optimiser can't see inside it. You'll still meet it in old codebases and in "what's the difference?" interview questions. Here is revenue per customer, computed both ways:

mongosh
db.orders.mapReduce(
  function () { emit(this.customerId, this.items.reduce((t, it) => t + it.qty * it.unitPrice, 0)); },
  function (key, values) { return Array.sum(values); },
  { query: { status: { $ne: "cancelled" } }, out: { inline: 1 } }
).results
db.orders.aggregate([
  { $match: { status: { $ne: "cancelled" } } },
  { $group: { _id: "$customerId",
              value: { $sum: { $sum: { $map: { input: "$items",
                                               in: { $multiply: ["$$this.qty", "$$this.unitPrice"] } } } } } } },
  { $sort: { _id: 1 } }
])
Result
DeprecationWarning: Collection.mapReduce() is deprecated. Use an aggregation instead.
See https://mongodb.com/docs/manual/core/map-reduce for details.
[
  { _id: 4, value: 249 },
  { _id: 3, value: 127 },
  { _id: 1, value: 1045 },
  { _id: 2, value: 1164 },
  { _id: 5, value: 1016 }
]
[
  { _id: 1, value: 1045 },
  { _id: 2, value: 1164 },
  { _id: 3, value: 127 },
  { _id: 4, value: 249 },
  { _id: 5, value: 1016 }
]
What’s happening
  1. The query option filters the input like a $match: cancelled order 4 is excluded.
  2. map runs once per order with this bound to the order, and emits (customerId, order total). Rohit's two orders emit (1, 957) and (1, 88).
  3. reduce is called with a key and an array of emitted values ([957, 88] for customer 1) and returns 1045. MongoDB may call reduce several times for the same key on partial results, so it must be associative and return the same type it receives. That's a classic source of map-reduce bugs.
  4. mongosh prints a DeprecationWarning before running it. The results are the same as the pipeline's, just unsorted.
  5. The pipeline version does the same in two native stages: $match, then $group with a $sum of each order's $map-computed line totals.
AdvancedSpeed: 100,000 documents both ways

Counting users per city on 100,000 documents, timed with Date.now() around each call, three runs each:

mongosh
const t0 = Date.now()
const mr = db.users.mapReduce(function () { emit(this.city, 1) }, function (k, v) { return Array.sum(v) },
                              { out: { inline: 1 } })
const t1 = Date.now()
const ag = db.users.aggregate([ { $group: { _id: "$city", value: { $sum: 1 } } } ]).toArray()
const t2 = Date.now()
({ mapReduceMs: t1 - t0, aggregateMs: t2 - t1, groups: ag.length, sameGroups: mr.results.length === ag.length })
Result
{ mapReduceMs: 983, aggregateMs: 19, groups: 11, sameGroups: true }
{ mapReduceMs: 988, aggregateMs: 29, groups: 11, sameGroups: true }
{ mapReduceMs: 1051, aggregateMs: 21, groups: 11, sameGroups: true }
What’s happening
  1. Both produce the same 11 city groups (the deprecation warning printed on each run is left out here).
  2. Map-reduce takes about a second (983–1,051 ms): it has to turn each BSON document into a JavaScript object, call map for it, and call reduce on batches of emitted values.
  3. $group takes 19–29 ms: a native hash aggregation that only reads the city field. That's roughly 45 times faster on this sandbox, and the gap is why map-reduce was deprecated.
AdvancedWriting results to a collection: $merge and $out

One thing map-reduce was often used for was writing its output to a collection (out: "collectionName"), a periodically refreshed summary. The pipeline equivalents are $out (replace the whole target collection) and $merge (4.2+: insert, replace, merge or keep per document, so you can refresh incrementally). A $merge target works like a materialised view:

mongosh
const summarise = [
  { $match: { status: { $ne: "cancelled" } } },
  { $group: { _id: "$customerId", orders: { $sum: 1 },
              revenue: { $sum: { $sum: { $map: { input: "$items",
                         in: { $multiply: ["$$this.qty", "$$this.unitPrice"] } } } } } } },
  { $set: { refreshedAt: "$$NOW" } },
  { $merge: { into: "customerRevenue", on: "_id", whenMatched: "replace", whenNotMatched: "insert" } }
]
db.orders.aggregate(summarise)
db.customerRevenue.find({}, { refreshedAt: 0 }).sort({ _id: 1 })
db.orders.insertOne({ _id: 9, customerId: 6, status: "paid", orderedAt: ISODate("2025-04-10T10:00:00Z"),
                      items: [ { productId: 3, qty: 1, unitPrice: 799 } ] })
db.orders.aggregate(summarise)
db.customerRevenue.find({ _id: 6 }, { refreshedAt: 0 })
Result
[
  { _id: 1, orders: 2, revenue: 1045 },
  { _id: 2, orders: 2, revenue: 1164 },
  { _id: 3, orders: 1, revenue: 127 },
  { _id: 4, orders: 1, revenue: 249 },
  { _id: 5, orders: 1, revenue: 1016 }
]
{ acknowledged: true, insertedId: 9 }
[ { _id: 6, orders: 1, revenue: 799 } ]
What’s happening
  1. $merge must be the last stage. The aggregate() call returns nothing; the results go into customerRevenue.
  2. on: "_id" matches summary documents to existing ones by customer id; whenMatched: "replace" overwrites a customer's old totals and whenNotMatched: "insert" adds new customers.
  3. After Zara's first order (_id 9) is inserted, re-running the same pipeline adds her row (799) and refreshes the others. Readers query customerRevenue cheaply instead of re-aggregating orders.
  4. $out does the same but replaces the whole collection atomically each time: db.orders.aggregate([{ $group: { _id: "$status", n: { $sum: 1 } } }, { $out: "statusCounts" }]) writes four status counts. Use $out for full rebuilds and $merge for incremental refreshes or writing into a collection that has other data.
  5. The test order 9 was deleted again afterwards, so the rest of this chapter still sees the original 8 orders.
AdvancedWhen you genuinely need custom JavaScript

For logic no operator can express, the pipeline has $function (a JavaScript expression) and $accumulator (a JavaScript accumulator for $group), both since 4.4. They're the documented replacement for map-reduce's custom code, but they run in the same JavaScript engine with the same per-document cost, and the docs ask you to use them only when the built-in operators can't do the job. Almost everything people used map-reduce for (sums, counts, distinct values, per-key arrays, top-N, joins, writing to a collection) is now a built-in stage or operator.

Aggregation pipelineMap-reduce
StatusCurrent, actively developedDeprecated since 5.0
Written inStages of native operatorsJavaScript map/reduce/finalize functions
Uses indexesYes, for $match/$sort at the start, and $lookupOnly through its query option
OptimiserReorders and merges stages; slot-based engineCan't see inside your functions
OutputCursor, or $out/$merge to a collectionInline, or to a collection
Speed (count per city, 100k docs, above)~20 ms~1,000 ms

SQL and MySQL written fresh; MongoDB built from Rohit’s “mongoDB interview” doc. Every query was run on MySQL 9.7, MongoDB 9.0, Redis 8.10 or PostgreSQL 18.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.