The aggregation pipeline, exam-style

After this lesson you will turn an exam sentence such as "the total number of X for each Y, returning only those with at least N" into a pipeline of $match, $unwind, $group, $sort and $limit — and say what the documents look like after every stage.

The exercise session asks: "Write a query to compute the total number of bedrooms available across all housings." The solution slide (p. 1500) answers with a single $group stage. Run it on the session's collection — MongoDB's AirBnB sample, 5,555 listings — and it prints bedroom_count: 0.

No error, no warning. The pipeline is valid, and MongoDB does exactly what it says. One character in it asks for something else. Try to find it while you read; the last section names it, together with the family of one-character slips that make a correct-looking pipeline answer a different question.

A pipeline is a conveyor belt of stages

Lesson 13's terminology table ended with a promise: what SQL calls "Aggregation (e.g. group by)", MongoDB calls the "aggregation pipeline" (p. 297). The lecture gives it one slide, "Further Read Features: Aggregates" (p. 317): "SQL-like aggregation functionality", "Pipeline documents from a collection pass through an aggregation pipeline", "Expressions produce output documents based on calculations performed on input documents". The exercise session turns those three lines into the tool behind most MongoDB queries of the exam (pp. 1439–1442).

Start with the first pipeline among the session's solutions, the answer to "Write a query to count the number of housings based on their price" (p. 1499):

// the number of housings for each price (p. 1499)
db.air_bnb_collection.aggregate([
  {
    "$group": {
      "_id": "$price",
      "housing_count": { "$sum": 1 }
    }
  }
])

Read it from the outside in. aggregate([…]) runs a pipeline on the collection: the square brackets hold an array of stages, applied in order, each one a document with a single key, the stage's name. Here there is one stage, $group. Its "_id": "$price" says which documents go together — those with the same price — and "housing_count": { "$sum": 1 } adds 1 for each document that joins a group. In SQL: SELECT price, COUNT(*) … GROUP BY price. Why not find? "When applying $unwind or $group stages (explained later), it is necessary to apply the aggregate(...) method" (p. 1439): find only filters and projects.

To watch a pipeline work you need data. This lesson uses five housings (Table 1). The first is Ribeira Charming Duplex, the document of the dataset slides (pp. 1472–1474), with the values they print — except its reviews: Théo's is on p. 1474, Bridget's is ours. The other four housings are ours too, chosen so that every stage of the session has something to bite on — a host with two housings, a missing cleaning fee, housings without reviews.

Table 1. The five housings of the lesson (and of both widgets). Prices as plain numbers; "—" means the field is missing from the document.
_idnamepricebedroomsbathroomscleaning_feeaccommodatescancellation_policyamenitieshost.host_idhost.host_has_profile_picaddress.countryreviews (reviewer_name)
"10006546"Ribeira Charming Duplex8031358moderateTV, Cable TV, Wifi"51399391"truePortugalThéo, Bridget
"2"Tiny Studio5011452flexibleIron, Wifi"H2"falsePortugalBridget
"3"Big Villa80526010strictTV, Iron, Wifi"H3"trueSpainAnna, Bridget
"4"River Loft12021507moderateWifi"51399391"truePortugalnone, [ ]
"5"Attic Room8001—4flexibleTV, Iron"H5"trueSpainnone, [ ]

On these five, the pipeline of p. 1499 returns three documents: { _id: 50, housing_count: 1 }, { _id: 80, housing_count: 3 } and { _id: 120, housing_count: 1 }. Five documents went in, three came out, and none of them is a housing any more.

Exam questions need more than one stage. The third aggregation exercise of the session filters before it counts: "Write a query to compute the total number of housings based on their cancellation policy, whose accommodates is greater than 6 and whose host has a profile picture" (p. 1501).

// housings per cancellation policy, after two filters (p. 1501)
db.air_bnb_collection.aggregate([
  { "$match": { "accommodates": { "$gt": 6 } } },
  { "$match": { "host.host_has_profile_pic": true } },
  {
    "$group": {
      "_id": "$cancellation_policy",
      "cancellation_policy_count": { "$sum": 1 }
    }
  }
])

$match is the filter of find moved inside the pipeline — "$match defines filters" (p. 1442) — with the syntax of lesson 14. Two $match stages in a row act as an AND; one $match holding both conditions is the same query. Fig. 1 follows the five housings along this belt.

air_bnb_collection the five housings of Table 1 in SQL 5 $match "accommodates": { "$gt": 6 } WHERE 3 Tiny Studio and Attic Room drop out $match "host.host_has_profile_pic": true WHERE 3 nobody drops out $group "_id": "$cancellation_policy" GROUP BY 2 three housings become two new documents moderate 2, strict 1
Fig. 1. The pipeline of p. 1501 on the five housings. The number on each arrow is how many documents travel to the next stage. The two documents at the bottom are { _id: "moderate", cancellation_policy_count: 2 } and { _id: "strict", cancellation_policy_count: 1 }: made by the $group, not stored anywhere.

The figure is the habit the exercise sessions ask for: "Do not focus only on memorising the commands required to perform queries. Instead, focus on understanding how the query shapes the data. Think on how the dataset would change after each operation in the query is performed" (introduction to the exercise sessions). The session teaches six stages; p. 1442 lists them — "besides the previously explained $group, $unwind, $sort, and $limit stages, $match defines filters while $project defines projections" — and Table 2 gives each its SQL twin.

Table 2. The six stages of the practical lectures (p. 1442) and what each does to the stream of documents.
StageWhat leaves the stageIn SQL
$matchthe documents that pass a filter, unchangedWHERE; after a $group, HAVING
$groupone new document per value of _id, with the results of its aggregation functionsGROUP BY with COUNT, SUM, AVG…
$unwindone copy of a document per element of an arrayjoining a row with its child rows
$sortthe same documents, reordered: 1 ascending, -1 descendingORDER BY
$limitthe first n documentsLIMIT
$projectthe documents with some fields kept (1) or removed (0)the SELECT list

"These stages can be applied interchangeably in the aggregation pipeline" (p. 1442). Read it as "in any order, as many times as you need" — not as "swapping two stages changes nothing". A $match on a count works only after the $group that creates the count, and a $limit placed before a $sort keeps arbitrary documents (section 4).

The lecture's next slide (p. 318), "Further Read Features: Map-Reduce", shows the older tool for the same job, and only its signature: db.collection.mapReduce( , , { out: , query: , sort: , limit: , … } ). The two empty slots before the options are where two JavaScript functions go: a map that emits key–value pairs from each document, and a reduce that combines the values of one key — the model of lesson 30, run inside the database. The current MongoDB manual deprecates map-reduce since version 5.0 in favour of the pipeline, and no exercise of the course uses it.

The $group stage: one bin per key

p. 1440 defines the stage: "Aggregate operations, i.e., operations aimed at grouping with respect to one or more fields, are achieved by applying the $group stage within the aggregate(...) method. Such a stage requires defining the list of fields to perform the aggregation and the aggregation functions to be applied." Picture a row of bins. The _id expression is written on each bin, every document drops into the bin of its value, and the aggregation functions are counters fixed to each bin. The MongoDB manual calls them accumulators; this lesson keeps the slides' name.

The key works because of the dollar. Inside $group, a string that starts with $ means "the value of this field in the current document"; without it, it is just a string. A $match filter is the exception: there, as in find, "$price" is plain text. p. 1441 shows both and draws the lesson: "Whenever a grouping operation is to be performed on the whole dataset, it is possible to apply a dummy _id in the $group stage. It's enough to set the _id to true or a fixed value. The latter is why the $ in the grouping stage is important!" Drop the dollar from "$price" and "price" is that fixed value (Fig. 2).

price of each housing 80 50 80 120 80 _id: 50 _id: 80 _id: 120 1 3 1 one bin per price

With the dollar: "_id": "$price"

price of each housing 80 50 80 120 80 _id: "price" 5 all five in one bin

Without it: "_id": "price"

Fig. 2. The same $group of p. 1499 on the five housings, with and without the dollar. The number in each bin is its housing_count. With the dollar the key is each housing's price: three bins. Without it the key is the word "price", the same for every housing: one bin, count 5.

Used on purpose, a fixed key answers "the total number of bedrooms available across all housings" (p. 1500): one bin for the whole collection, and $sum over a field instead of over 1.

// total bedrooms, one group for all (p. 1500, corrected)
db.air_bnb_collection.aggregate([
  {
    "$group": {
      "_id": true,
      "bedroom_count": { "$sum": "$bedrooms" }
    }
  }
])

On the five housings: { _id: true, bedroom_count: 11 }, that is 3 + 1 + 5 + 2 + 0. "_id": null works the same way. The slide's own version differs from this one by a single character — keep the hook in mind.

The slide lists them: "sum, avg, min, max, etc." (p. 1440). Table 3 runs each on the five housings.

Table 3. Aggregation functions of a $group, with their values on the five housings (computed on MongoDB 8.2).
Aggregation functionValue for each groupOn the five housings
{ "$sum": 1 }the number of documentsper price: 1, 3, 1
{ "$sum": "$bedrooms" }the total of the fieldwhole collection: 11
{ "$avg": "$price" }the mean of the field, over the documents that have itper policy: flexible 65, moderate 100, strict 80
{ "$min": "$price" }, { "$max": "$price" }the smallest and the largest valueper country: Portugal 50 and 120, Spain 80 and 80
{ "$push": "$name" }the list of the values — not on the slidesper policy: flexible ["Tiny Studio", "Attic Room"]

To group by two fields at once, write _id as a sub-document. p. 1504 groups by "_id": { "n_bed": "$bedrooms", "n_bath": "$bathrooms" }: one bin per pair, with names you choose on the left and field paths, dollar included, on the right. Its output keys look like { n_bed: 5, n_bath: 2 }; the whole query is in section 3.

What survives? "Whenever a $group stage is applied, only the fields used to perform the aggregation or created by it will be available in the next stages" (p. 1440). After the stage of p. 1499 each document is { _id, housing_count } and nothing else: name, bedrooms, amenities are gone, and a later stage that reads them finds nothing — a $match on accommodates placed after this $group lets no document through. It is SQL's rule after GROUP BY: only the grouped columns and the aggregates remain.

After a $group, a document is its _id and the fields its aggregation functions created — nothing else goes on.

Arrays need $unwind; groups are filtered after grouping

"Write a query to compute the total number of housings for each amenity" (p. 1485) hides a problem: amenities is an array, and $group sees whole documents. Group by "$amenities" directly and each whole list becomes a key — five bins of one housing each (try it in the widget). The array has to be taken apart first, which is what $unwind does: "It shapes the collection so that each document is replaced with a set of new ones, i.e., one for each element in the document's array on which the unwind stage is applied. These new documents contain all the fields from the main one and a field with the name of the array field that contains one of its documents" (p. 1439).

air_bnb_collection { _id: "10006546", price: 80, amenities: [ "TV" "Cable TV" "Wifi" ] } one document

Before: one housing, an array of three

{ _id: "10006546", price: 80, { _id: "10006546", price: 80, { _id: "10006546", price: 80, amenities: amenities: amenities: "TV" "Cable TV" "Wifi" } } } three documents

After { "$unwind": "$amenities" }: one element each

Fig. 3. $unwind on Ribeira (fields other than price left out). The three copies keep every field of the original, _id included; only amenities changes, from the array to one of its elements (blue). A $group by "$amenities" now sees one amenity per document.

After the unwind, amenities holds one string per document, and grouping by it groups by amenity (p. 1502):

// the number of housings for each amenity (p. 1502)
db.air_bnb_collection.aggregate([
  { "$unwind": { "path": "$amenities" } },
  {
    "$group": {
      "_id": "$amenities",
      "housing_with_amenity_count": { "$sum": 1 }
    }
  }
])

The five housings become 3 + 2 + 3 + 1 + 2 = 11 documents, and the 11 fall into four bins: Wifi 4, TV 3, Iron 3, Cable TV 1. The slide writes the long form { "path": "$amenities" }; { "$unwind": "$amenities" } is the same stage. Either way the path has its dollar: it reads a field.

A document whose array is empty or missing yields no copy at all. River Loft and Attic Room have no reviews, so unwinding "$reviews" leaves three housings. Harmless when you count elements; a trap when you count parents — "the number of reviews of each housing" would lose the housings with none instead of showing 0. The manual's option "preserveNullAndEmptyArrays": true keeps them once; the slides do not use it.

Now a condition on the bins: "returning only the amenities whose count is greater than 15" (p. 1503).

// amenities offered by more than 15 housings (p. 1503)
db.air_bnb_collection.aggregate([
  { "$unwind": { "path": "$amenities" } },
  {
    "$group": {
      "_id": "$amenities",
      "housing_with_amenity_count": { "$sum": 1 }
    }
  },
  { "$match": { "housing_with_amenity_count": { "$gt": 15 } } }
])

A $match after the $group sees bins, not housings: it filters by what the group computed, under the exact name the $group gave it. It cannot move up — before the $group the field it tests does not exist yet. This is SQL's HAVING; the $match before a group is its WHERE. On five housings no amenity reaches 16, and the empty answer is correct. In the widget below you can lower the threshold.

p. 1504 combines a compound key with two HAVING filters:

// averages per (bedrooms, bathrooms), two filters (p. 1504)
db.air_bnb_collection.aggregate([
  {
    "$group": {
      "_id": { "n_bed": "$bedrooms", "n_bath": "$bathrooms" },
      "avg_price": { "$avg": "$price" },
      "avg_cleaning_fees": { "$avg": "$cleaning_fee" }
    }
  },
  { "$match": { "avg_price": { "$gt": 60 } } },
  { "$match": { "avg_cleaning_fees": { "$gt": 40 } } }
])

Before the filters there are five bins, one per (bedrooms, bathrooms) pair. avg_price > 60 removes (1, 1), Tiny Studio's 50. avg_cleaning_fees > 40 removes (3, 1), Ribeira's 35, and (0, 1): Attic Room has no cleaning_fee, its average is null, and null is not greater than 40. Two bins remain: (2, 1) with 120 and 50, (5, 2) with 80 and 60. Note the names: the field is cleaning_fee, the created field avg_cleaning_fees. The names a $group creates are yours; field names are the data's.

A $match takes any filter of lesson 14, array operators included. The session's last two filters are written as pipelines (pp. 1505–1506):

// housings with one review that has both values (p. 1505)
db.air_bnb_collection.aggregate([
  {
    "$match": {
      "reviews": {
        "$elemMatch": {
          "listing_id": "10006546",
          "reviewer_id": "11263097"
        }
      }
    }
  }
])
// a review of 10006546 and a review by Bridget (p. 1506)
db.air_bnb_collection.aggregate([
  { "$match": { "reviews.listing_id": "10006546" } },
  { "$match": { "reviews.reviewer_name": "Bridget" } }
])

The first returns Ribeira alone: its second review, by reviewer 11263097, has both values. The second also returns Big Villa, whose first review carries listing id 10006546 and whose second is by Bridget. Same two conditions, two questions: "one review with A and B" against "one with A and one with B" — the distinction of pp. 1436–1438, taught in lesson 14.

The pipeline, one stage at a time

The five housings of Table 1. Choose a pipeline, then press Next stage — or click a stage on the belt — to see the documents that leave each stage; a dashed card is one the stage dropped, a yellow line what the stage touched. To build your own, choose a stage in the palette and add it to the end; a $group takes the key and the aggregation function from the two menus under the palette. N is the number in the stages that show N. Every result is computed on the five housings, not printed on a slide.

  1. Choose "p. 1501 · filter twice, count per policy". Before pressing Next stage: how many documents leave each of the three stages? Then step through.
    What you should see

    5 → 3 → 3 → 2. The first $match drops Tiny Studio (2 guests) and Attic Room (4); the second drops nobody, since all three remaining hosts have a profile picture; the $group turns three housings into two documents that are not housings at all, moderate 2 and strict 1. That is the insight of the widget: each stage changes the number and the shape of the documents, and the next stage sees only what the previous one passed on.

  2. Choose "p. 1503 · amenities with count > N" (N = 15, as on the slide). Predict the result, then step to the end. Now lower N: at which N does the first amenity appear?
    What you should see

    Nothing passes at 15: five housings cannot give 16. Wifi appears at N = 3 (its count is 4); at N = 2 TV and Iron, with 3 each, join it; Cable TV needs N = 0. The $match filters bins by housing_with_amenity_count, a field that exists only from the $group on.

  3. Choose "p. 1500 · total bedrooms, as printed" and run it to the end; then "p. 1500 · total bedrooms, corrected". What does each return?
    What you should see

    0 and 11. The printed pipeline sums "$bedroom", a field no housing has, and $sum skips what is missing without an error. The cards show the real field, bedrooms, next to it.

  4. Choose "your own pipeline". In the palette choose the $group stage, with the key cancellation_policy and the aggregation function "count", and add it; then add "$match accommodates > N", with N = 6. Predict the result before you look.
    What you should see

    No document. After the $group the documents are { _id, count }: accommodates no longer exists, so nothing passes. The same two stages in the other order answer most of p. 1501.

Top N, and counting distinct things

$sort and $limit are the pipeline's ORDER BY and LIMIT, with the numbers lesson 14 used for sort(): 1 ascending, -1 descending. A "top N" question is a count, a sort and a cut:

// the three amenities offered by the most housings
db.air_bnb_collection.aggregate([
  { "$unwind": { "path": "$amenities" } },
  { "$group": { "_id": "$amenities",
                "housing_with_amenity_count": { "$sum": 1 } } },
  { "$sort": { "housing_with_amenity_count": -1 } },
  { "$limit": 3 }
])

Result: Wifi 4, then TV and Iron with 3 — in either order, since MongoDB promises no order among equal values; add a second key, "_id": 1, when the order matters. Keep $sort before $limit. With find, MongoDB applies sort() before limit() however you chain them; a pipeline runs its stages as written. { "$limit": 2 } placed first keeps the first two housings in storage order — Ribeira (8 guests) and Tiny Studio (2) — and only then sorts them, while the right order returns Big Villa (10) and Ribeira (8).

$project keeps the fields marked 1 (and _id) or removes those marked 0. p. 1442 chains five of the six stages — all but $limit — on the phone numbers of the session's people collection:

// providers with over 10 numbers, count hidden (p. 1442)
db.people_collection.aggregate([
  { "$unwind": { "path": "$mobile_phone_numbers" } },
  {
    "$group": {
      "_id": {
        "provider": "$mobile_phone_numbers.service_provider"
      },
      "phone_numbers_per_provider": { "$sum": 1 }
    }
  },
  { "$match": { "phone_numbers_per_provider": { "$gt": 10 } } },
  { "$sort": { "phone_numbers_per_provider": -1 } },
  { "$project": { "phone_numbers_per_provider": 0 } }
])

Read it aloud: one document per phone number; one bin per provider, with its count; keep the providers with more than 10 numbers; largest first; hide the count. Sorting by a field and removing it in the very next stage is fine — by then the order is made.

The session ends with a different kind of count: "For each country, compute the total number of unique hosts" (p. 1507).

// for each country, the number of distinct hosts (p. 1507)
db.air_bnb_collection.aggregate([
  {
    "$group": {
      "_id": {
        "country": "$address.country", "host_id": "$host.host_id"
      },
      "duplication_count_per_host": { "$sum": 1 }
    }
  },
  {
    "$group": {
      "_id": "$_id.country",
      "unique_host_count": { "$sum": 1 }
    }
  }
])

A single $group by country with { "$sum": 1 } counts housings: Portugal 3, because host 51399391 has two housings there. The session groups twice. The first $group makes one document per (country, host) pair, however many housings the pair has — the duplicates collapse here. The second groups those pair-documents by country, reading it inside the compound key with "$_id.country", and counts them: Portugal 2, Spain 2. duplication_count_per_host is not needed by the question; it only shows what collapsed. In SQL this is COUNT(DISTINCT host_id) … GROUP BY country, an aggregate the taught stages do not have.

To count distinct things per key: group by (key, thing), then group by key and count.

Unique hosts per country: two $group stages

The five listings of Table 1, by country and host. Next runs the two stages of p. 1507: first the (country, host) bins, then the country bins. The dashed panel is the tempting single $group by country. Add a sixth listing and watch which number moves.

  1. Before pressing Next: how many documents leave the first $group?
    What you should see

    Four. Host 51399391 has two listings in Portugal, Ribeira and River Loft; they share the pair (Portugal, 51399391) and collapse into one document with duplication_count_per_host 2. The other three pairs have one listing each.

  2. Add "a third listing of host 51399391, in Portugal" — the dataset slide gives this host host_listings_count: 3 (p. 1473). Which answer moves: the two-stage one or the single group?
    What you should see

    Only the single group: Portugal goes from 3 to 4 listings. The pair (Portugal, 51399391) just grows to duplication_count_per_host 3, so unique_host_count stays 2. The insight: the first $group removes duplicates — one document per (country, host) — and the second counts what is left.

  3. Choose instead "a listing of host 51399391 in Spain". What happens to Spain, and is it right?
    What you should see

    Spain becomes 3 in both answers: a new pair, (Spain, 51399391), appears. The host counts once in each country where it has a listing — exactly what "for each country, the number of unique hosts" asks.

From the exam's sentence to the stages

The paper of 30 January 2026 ends every MongoDB query with "Use only the pipeline stages discussed during the practical lectures." Those are the six of Table 2. $lookup, the stage that joins two collections, is not among them, and the same paper bans it in the design question: "You must design the relationships without using the MongoDB $lookup operation." So every query runs on one collection, over documents nested as lesson 13 designed them. Other stages exist — $count, $sortByCount, $addFields — but were not discussed: leave them out. Aggregation functions such as $push or $first are not stages; prefer the slide's sum, avg, min and max where they suffice, and say in a comment what any other one does.

One habit before the first stage: your query can only be read against your own documental representation, so state it — or point to your design answer — and name the arrays you will unwind. Then translate the sentence phrase by phrase. Here is the method on the exam of 12 September 2025, exercise 5 ("tracking farmers and their sales at local markets"). Its ER has Farm (Name, Address, State, Description) and Animal (Tag, Name, Age, Animal Type (e.g., Cow), Breed), joined by House, with Housing Date: Animal (1, 1) – Farm (1, N). The query: "Write a query to compute the total number of farms that house at least 10 cows of at least 5 different breeds. (2 PT)"

// assumed: collection farm, animals nested in each farm
{
  "_id": "Green Acres",                     // Name: String
  "Address": "SP 12, km 3",                 // String
  "State": "Italy",                         // String
  "Description": "Dairy farm",              // String
  "animals": [
    { "Tag": "A-001",                       // String
      "Name": "Bessy",                      // String
      "Age": 6,                             // Integer
      "Animal_Type": "Cow",                 // String
      "Breed": "Holstein",                  // String
      "Housing_Date": ISODate("2020-03-01") // Date, of House
    }
  ]
}
Table 4. The sentence of the exam, phrase by phrase, and the stage each phrase becomes.
The sentence saysStageWhy
"farms that house … cows"$unwind animalsthe cows are elements of the array animals
"cows"$match on animals.Animal_Typea condition on one element: after the unwind
"of … different breeds"$group by (farm, breed)one document per breed of each farm: duplicates collapse
"at least 10 cows of at least 5 breeds"$group by farm, then $matchtwo counts per farm, both tested HAVING-style; "at least" is $gte
"the total number of farms"$group by trueone bin for all the farms that survive
// farms with at least 10 cows of at least 5 breeds
db.farm.aggregate([
  { "$unwind": { "path": "$animals" } },
  { "$match": { "animals.Animal_Type": "Cow" } },
  { "$group": { "_id": { "farm": "$_id",
                         "breed": "$animals.Breed" },
                "cows_of_breed": { "$sum": 1 } } },
  { "$group": { "_id": "$_id.farm",
                "n_breeds": { "$sum": 1 },
                "n_cows": { "$sum": "$cows_of_breed" } } },
  { "$match": { "n_cows": { "$gte": 10 },
                "n_breeds": { "$gte": 5 } } },
  { "$group": { "_id": true, "n_farms": { "$sum": 1 } } }
])

The second $group adds up two things: 1 per (farm, breed) document, which counts the breeds, and the field cows_of_breed, which counts the cows. On five test farms the pipeline turns 77 animals into 47 cows after the $match, then 25 (farm, breed) pairs, five farms with their two counts, two farms over both thresholds and finally { _id: true, n_farms: 2 }. Leave out the $match on Cow and a farm with six cows and ten sheep slips in — 16 animals of 8 breeds — and the answer becomes 3. One more detail is worth a comment in your answer: if no farm qualifies, the last $group receives nothing and returns no document, not a 0.

"More than N" is $gt, "at least N" is $gte. "For each X" decides the _id. "Distinct" or "different" asks for a double $group. "Top 10" is $sort with -1, then $limit. A condition on an element of an array goes after its $unwind. The slides leave a comma after the last stage (pp. 1503, 1504, 1506 and 1507): the shell accepts it, but leave it out on paper.

Silent wrong answers, and the missing letter

Back to the hook. p. 1500 sums "$bedroom", and the field is bedrooms — "bedrooms: 3" on the dataset slide (p. 1472). $sum adds the values that are numbers and skips the rest; a field that does not exist is not a number; the total of nothing is 0. MongoDB raises no error because it has no schema to check names against: a misspelt field is simply a field no document has. That is schema-on-read (lesson 4) seen from the query side.

The missing letter has relatives. Each row of Table 5 is a pipeline that runs, returns something and is wrong; all were run on MongoDB 8.2.

Table 5. One-character slips that MongoDB accepts without a word.
SlipMongoDB returnsOn the five housings
a misspelt field in $sum0"$bedroom": 0 instead of 11
a missing field in $avgnull, which then fails every comparison such as $gtAttic Room's avg_cleaning_fees: null
the key without its dollarone group for everything"price": one document, count 5
a misspelt field as the keyone group whose _id is null"$bedroom": { _id: null, n: 5 }
a $match on an original field after $groupno documentaccommodates > 6 after grouping by policy: empty
$limit before $sortarbitrary documents, sortedtop 2 by accommodates: Ribeira and Tiny Studio
a condition on elements placed before $unwindevery element of a matching parent countedexam question 3 below: 150 puzzles counted instead of 80
a number typed as a stringno match{ "$gt": "6" } on accommodates: empty (lesson 14)

MongoDB never complains about a field it cannot find: check every name against the ER diagram and every stage against the documents it receives.

Exam-style questions

Four practical exercises from past papers, each with the paper's ER scenario, the documental representation it assumes (the designs of lesson 13) and the points printed on the paper; then two theory questions in the forms of the theory part, where wrong closed answers may cost points. No solutions are published: the model answers are ours, and each pipeline was run on a real MongoDB server on test data built to hit its thresholds.

Exam of 30 Jan 2026, exercise 3 (a hospital heating management system). ER: Hospital (Hospital_ID, Name); Building (Building_ID, Name, Year); Department (Department_ID, Specialty, N_Beds, Head_Physician_ID); Person (P_ID, Email). Has Building: Hospital (1:N) – Building (1:1); Has Dept.: Hospital (1:N) – Department (1:1); Works For, with Role: Department (1:N) – Person (1:N). Assume the documental representation of the design question: one collection, hospital; buildings and departments nested in each hospital; the people of each department nested in it, each with its Role.

"Write a query to return the total number of departments each person with the role 'maintenance' works for. Return only the top 10 people working in the highest number of departments. (2 PT) Use only the pipeline stages discussed during the practical lectures."

Show model answer

Assumed documents (as designed in the first part): { "_id": "H01", "Name": …, "buildings": [ { "Building_ID", "Name", "Year" } ], "departments": [ { "Department_ID", "Specialty", "N_Beds", "Head_Physician_ID", "people": [ { "P_ID", "Email", "Role" } ] } ] }. Role belongs to Works For, so it is read per department: a person counts the departments in which they work as maintenance.

// top 10 maintenance people by number of departments
db.hospital.aggregate([
  { "$unwind": { "path": "$departments" } },
  { "$unwind": { "path": "$departments.people" } },
  { "$match": { "departments.people.Role": "maintenance" } },
  { "$group": { "_id": "$departments.people.P_ID",
                "n_departments": { "$sum": 1 } } },
  { "$sort": { "n_departments": -1 } },
  { "$limit": 10 }
])

The two $unwind stages give one document per (department, person). The $match keeps the maintenance entries; the $group counts them per P_ID — a person appears once per department, so the count is the number of departments; $sort with -1 and $limit keep the ten highest.

Check three things in your answer: both unwinds before the condition on Role, the key with its dollar ("$departments.people.P_ID"), and $sort before $limit. The question has a second reading — all the departments of the people who are maintenance somewhere — which needs more work; say which one you answer. Tested on twelve maintenance workers spread over 12 to 1 departments: the ten with 12 down to 3 come back, in order.

Exam of 30 Jan 2026, exercise 3, continued. Same ER and same documents.

"Write a query to collect, for each hospital with more than 10 buildings, the number of departments with more than 25 people. Use only the pipeline stages discussed during the practical lectures. (2 PT)"

Show model answer
// hospitals with > 10 buildings: departments with > 25 people
db.hospital.aggregate([
  { "$unwind": { "path": "$buildings" } },
  { "$group": { "_id": "$_id",
                "n_buildings": { "$sum": 1 },
                "departments": { "$first": "$departments" } } },
  { "$match": { "n_buildings": { "$gt": 10 } } },
  { "$unwind": { "path": "$departments" } },
  { "$unwind": { "path": "$departments.people" } },
  { "$group": {
      "_id": { "hospital": "$_id",
               "department": "$departments.Department_ID" },
      "n_people": { "$sum": 1 } } },
  { "$match": { "n_people": { "$gt": 25 } } },
  { "$group": { "_id": "$_id.hospital",
                "n_departments": { "$sum": 1 } } }
])

Buildings are counted with $unwind and $group, and the hospitals with more than 10 are kept. That $group would drop the departments, so they are carried through it with the aggregation function $first, which keeps the value of the first document of the group — every copy of a hospital carries the same departments array. Then people are counted per (hospital, department), the departments with more than 25 are kept, and they are counted per hospital.

A hospital with more than 10 buildings but no department over 25 people does not appear at all: no document reaches the last stage. Say so in a comment if you think a 0 is expected. A shorter answer with the same taught stages starts with { "$match": { "buildings.10": { "$exists": true } } } — an element at position 10, that is at least 11 buildings — and drops the first $unwind and $group. Both versions, tested on hospitals with 11, 10, 12 and 0 buildings, return only the first, with 2 departments (26 and 30 people; one of 5 excluded).

Exam of 30 May 2025 (its header prints June 30, 2025), exercise 5 (tracking puzzles' production and sales). ER: Company (Code, Name, Address, Phone Number, Biography); Puzzle (Puzzle ID, Name, Collection Name, Number of Pieces, Width, Height, Description); Produce, with Production Date: Company (1, N) – Puzzle (1, 1). Assume the documental representation of the design question: collection company, its puzzles nested in it, each with its Production_Date.

"Write a query to compute the total number of puzzles produced by each company, considering only puzzles produced after 30/04/1995 and with more than 500 pieces, and returning only the companies that produced more than 100 puzzles (1.5 PT)"

Show model answer
// companies with more than 100 qualifying puzzles
db.company.aggregate([
  { "$unwind": { "path": "$puzzles" } },
  { "$match": {
      "puzzles.Production_Date": { "$gt": ISODate("1995-04-30") },
      "puzzles.Number_of_Pieces": { "$gt": 500 } } },
  { "$group": { "_id": "$_id", "n_puzzles": { "$sum": 1 } } },
  { "$match": { "n_puzzles": { "$gt": 100 } } }
])

One document per puzzle, the two conditions on that puzzle, a count per company, and the threshold on the count. Production_Date is assumed stored as a date, so "after 30/04/1995" is $gt that day.

The point of the exercise is the place of the first $match. Put it before the $unwind and it keeps every company with at least one such puzzle — then counts all of its puzzles: on our test data a company with 80 qualifying puzzles out of 150 was counted as 150 and passed the threshold. "More than 100" is $gt: a company with exactly 100 is out.

Exam of 13 Jun 2025 (its header prints Jan. 21, 2025), exercise 3 (tracking concert attendances and performances). ER: Orchestra (Name, Foundation Date, Biography); Musician, a child of Person (Personal ID, Name, Surname, Birth Date), with Biography; Instrument (Name, Type, Description). Compose: Orchestra (10,N) – Musician (1,2); Play, with Since: Musician (1,N) – Instrument (1,N). Assume the documental representation of the design question: collection orchestra; its musicians nested in it, each with the instruments they play and Since.

"Write a query to compute the number of members for each orchestra and each instrument, and return only the orchestras that include at least three musicians for at least four instruments (2.5 PT)"

Show model answer
// orchestras with 3+ members for each of 4+ instruments
db.orchestra.aggregate([
  { "$unwind": { "path": "$musicians" } },
  { "$unwind": { "path": "$musicians.instruments" } },
  { "$group": {
      "_id": { "orchestra": "$_id",
               "instrument": "$musicians.instruments.Name" },
      "n_members": { "$sum": 1 } } },
  { "$match": { "n_members": { "$gte": 3 } } },
  { "$group": {
      "_id": "$_id.orchestra",
      "n_instruments": { "$sum": 1 },
      "instruments": { "$push": { "instrument": "$_id.instrument",
                                  "n_members": "$n_members" } } } },
  { "$match": { "n_instruments": { "$gte": 4 } } }
])

The first $group computes the members of each (orchestra, instrument); the first $match keeps the instruments with at least three; the second $group counts those instruments per orchestra and keeps their counts in a list; the last $match keeps the orchestras with at least four.

Two levels of HAVING, the pattern of the distinct count. $push is an aggregation function the slides do not show: without it the answer still returns the orchestras with their number of instruments. A musician may belong to two orchestras (Compose (1,2)) and is stored in both, but appears once inside each, so no member is counted twice per orchestra. Tested: an orchestra with three players for each of piano, violin, cello and flute is returned with 4; one with only two flautists is not.

Which of the following lists contains only stages of the MongoDB aggregation pipeline?

  • $match, $group, $unwind, $sort, $limit, $project The six stages of p. 1442: $group, $unwind, $sort, $limit, and "$match defines filters while $project defines projections".
  • $match, $elemMatch, $group $elemMatch is a query operator: it lives inside a filter, in find or in a $match. One session slide calls it a stage (p. 1438), but it never stands alone in the pipeline.
  • $group, $sum, $avg $sum and $avg are aggregation functions (accumulators, in the manual), written inside a $group.
  • $unwind, $exists, $sort $exists is an element query operator, used inside a filter.
Show model answer

The first list. A stage is an element of the array passed to aggregate(...) and transforms the stream of documents; query operators and aggregation functions are written inside stages.

A closed question where a wrong tick may cost points: the trap is the slide that calls $elemMatch a stage. Know the six names of p. 1442 by heart.

Explain how the $group stage of the MongoDB aggregation pipeline works: the role of _id and of the aggregation functions, the meaning of the $ in "_id": "$price", and which fields the next stage can use. How do you compute a single total over the whole collection?

Show model answer

The $group stage partitions the documents it receives by the value of the expression in _id and outputs one new document per distinct value. The other fields of the stage are aggregation functions (accumulators, in the manual), computed on the documents of each group: { "$sum": 1 } counts them, { "$sum": "$field" } adds a field, $avg, $min and $max give the mean, the minimum and the maximum. A string that starts with $ is a field path: "$price" is the value of price in each document, so there is one group per price; "price" without the dollar is a constant, and all documents fall into one group. After the stage only _id and the fields created by the aggregation functions exist; to filter on one of them, a $match is placed after the $group, like HAVING in SQL. For a total over the whole collection the _id is set to a constant such as true or null, for example { "$group": { "_id": true, "bedroom_count": { "$sum": "$bedrooms" } } }.

A complete answer covers four things: partitioning by _id, aggregation functions with at least two examples, the dollar as field path against a constant, and what survives the stage — "only the fields used to perform the aggregation or created by it" (p. 1440). The dummy _id of p. 1441 closes it. The slides say "aggregation functions"; "accumulators" is the manual's word for the same thing.

Check yourself

A pipeline reaches { "$group": { "_id": "$cancellation_policy", "n": { "$sum": 1 } } }. Which fields can the next stage read? Select all that apply.

  • _id It holds the policy: "moderate", "flexible" or "strict".
  • n The field the stage created with its aggregation function.
  • cancellation_policy Its value now lives in _id; the field itself did not survive. Write "$_id" to read the policy.
  • price Only the key and the created fields go on (p. 1440). Carry what you need with an aggregation function, such as { "$avg": "$price" }.

After a $group, a document is its _id and the fields its aggregation functions created.

{ "$unwind": "$amenities" } runs on the five housings of Table 1. How many documents come out?

  • 11 One per element: 3 + 2 + 3 + 1 + 2.
  • 5 The stage does not keep one document per housing: it makes one per element of the array.
  • 4 4 is the number of different amenities — what a $group by "$amenities" makes after the unwind.
  • 3 3 is the length of Ribeira's array alone; every housing is unwound.

You want the number of reviews of each housing, the ones without reviews included. You unwind "$reviews", then group by "$_id" with { "$sum": 1 }. What happens to River Loft, whose reviews are [ ]?

  • It is missing from the result An empty array produces no copy, so River Loft never reaches the $group.
  • It appears with count 0 Nothing of River Loft reaches the $group, so there is no bin to show a 0.
  • It appears with count 1 Only with the manual's option "preserveNullAndEmptyArrays": true, which keeps the document once — and then the count is 1, not 0.
  • The pipeline stops with an error An empty array is legal; the stage simply produces nothing for it.

Two pipelines differ only in the key, "_id": "$price" against "_id": "price", both with "housing_count": { "$sum": 1 }. What does the second return on the five housings?

  • One document: { _id: "price", housing_count: 5 } Without the dollar, "price" is a fixed value, the same for every housing: one bin (p. 1441).
  • Three documents, like the first Three bins need three different key values; a constant has one.
  • An error: a key must start with $ Any constant is a legal key — that is how the dummy _id of p. 1441 works.
  • No document Every housing enters the stage, and all of them land in the same bin.

The question: "return the three hosts with the most housings". Which order of stages answers it?

  • $group by host with a count → $sort by the count, -1 → $limit 3 Count, order, cut: the top three.
  • $group → $limit 3 → $sort The $limit keeps three arbitrary hosts, then sorts only those.
  • $sort → $limit 3 → $group This cuts three housings, not three hosts, and counts too late.
  • $limit 3 → $group → $sort It counts only three housings.

Why does the first $group of p. 1507 use the key { country, host_id } and not host_id alone?

  • So that each (country, host) pair becomes one document, and the country is still there for the second $group Duplicates collapse per pair, and the country survives inside _id, where "$_id.country" reads it.
  • Because a key needs at least two fields One field is enough: p. 1499 groups by "$price" alone.
  • To sort the hosts by country $group does not sort; its output order is not even guaranteed.
  • Because host_id is a string The type of the key does not matter to the stage.

The flexible group holds Tiny Studio (cleaning_fee 45) and Attic Room, which has no cleaning_fee. What does { "$avg": "$cleaning_fee" } give for the group?

  • 45 The average is taken over the documents that have a number in the field; Attic Room is skipped, not counted as 0.
  • 22.5 That would count the missing fee as 0.
  • null null comes only when no document of the group has the field — Attic Room's (0, 1) bin when the pipeline of p. 1504 runs on the five housings.
  • An error A missing field is never an error in an aggregation function.

Key take-aways

What to watch and read