MongoDBDatabases

MongoDB Aggregation Pipeline: Stages, Operators & Real Examples

Master the MongoDB aggregation pipeline: $match, $group, $project, $sort, $lookup, $unwind, $facet, and $bucket with real-world analytics examples. Lesson 6 of MongoDB & NoSQL Mastery.

TT
Sarah Mitchell
6 min read
MongoDB Aggregation Pipeline: Stages, Operators & Real Examples

The aggregation pipeline is MongoDB's answer to SQL's GROUP BY, HAVING, JOIN, and window functions. You pass documents through a sequence of stages — each stage transforms the data and passes results to the next. Understanding the pipeline unlocks real-time analytics, reporting, and data transformation directly in the database, without pulling data into application memory.

Previous: Lesson 5 — Querying MongoDB


Pipeline Fundamentals

A pipeline is an array of stage documents passed to db.collection.aggregate():

javascript
db.orders.aggregate([
  { $match: { status: "shipped" } },       // Stage 1: filter
  { $group: { _id: "$customerId", total: { $sum: "$amount" } } }, // Stage 2: aggregate
  { $sort: { total: -1 } },                // Stage 3: sort
  { $limit: 10 }                           // Stage 4: top 10
])

Stages execute in order. Each stage receives the output of the previous stage. $match and $limit early in the pipeline reduce the document set before expensive stages like $group and $lookup.


Core Stages

$match — Filter Documents

Identical to a find() query filter. Always place $match as early as possible:

javascript
// Only process orders from 2026
{ $match: { createdAt: { $gte: ISODate("2026-01-01"), $lt: ISODate("2027-01-01") } } }

// Combine multiple conditions
{ $match: { status: { $in: ["shipped", "delivered"] }, amount: { $gt: 0 } } }

$group — Aggregate and Summarise

$group collapses multiple documents into one per unique _id value:

javascript
// Revenue per customer
{ $group: {
  _id: "$customerId",
  totalRevenue:  { $sum: "$amount" },
  orderCount:    { $sum: 1 },
  avgOrderValue: { $avg: "$amount" },
  firstOrder:    { $min: "$createdAt" },
  lastOrder:     { $max: "$createdAt" }
}}

// Group by multiple fields
{ $group: {
  _id: { year: { $year: "$createdAt" }, month: { $month: "$createdAt" } },
  revenue: { $sum: "$amount" }
}}

// Grand total (group all documents)
{ $group: { _id: null, total: { $sum: "$amount" } } }

Accumulator operators: $sum, $avg, $min, $max, $first, $last, $push (collect into array), $addToSet (unique values).

$project — Reshape Documents

javascript
// Include only specific fields and add computed fields
{ $project: {
  _id: 0,
  customerId: 1,
  amount: 1,
  year:  { $year: "$createdAt" },
  month: { $month: "$createdAt" },
  discountedAmount: { $multiply: ["$amount", 0.9] }
}}

// Rename a field
{ $project: { orderTotal: "$amount", _id: 0 } }

// Conditional value
{ $project: {
  tier: {
    $cond: { if: { $gte: ["$amount", 500] }, then: "premium", else: "standard" }
  }
}}

$sort, $limit, $skip

javascript
{ $sort: { totalRevenue: -1 } }   // Descending
{ $limit: 5 }
{ $skip: 20 }

$count — Count Pipeline Documents

javascript
// Count total matching documents
db.orders.aggregate([
  { $match: { status: "cancelled" } },
  { $count: "cancelledOrders" }
])
// → [{ cancelledOrders: 142 }]

$unwind — Flatten Arrays

$unwind deconstructs an array field into one document per element:

javascript
// Order document
{ _id: 1, items: [{ product: "Keyboard", qty: 1 }, { product: "Mouse", qty: 2 }] }

// After $unwind: { items }
{ _id: 1, items: { product: "Keyboard", qty: 1 } }
{ _id: 1, items: { product: "Mouse",    qty: 2 } }

Combined with $group, this is how you calculate totals across line items:

javascript
db.orders.aggregate([
  { $unwind: "$items" },
  { $group: {
      _id: "$items.product",
      totalQtySold: { $sum: "$items.qty" },
      revenue:      { $sum: { $multiply: ["$items.qty", "$items.price"] } }
  }},
  { $sort: { revenue: -1 } }
])

$lookup — Join Collections

$lookup performs a left outer join with another collection:

javascript
// Join orders with customer details
db.orders.aggregate([
  { $lookup: {
      from: "customers",       // collection to join
      localField: "customerId", // field in orders
      foreignField: "_id",      // field in customers
      as: "customer"            // output array field name
  }},
  { $unwind: "$customer" },    // flatten the single-element array
  { $project: {
      orderId: "$_id",
      customerName: "$customer.name",
      amount: 1
  }}
])

$lookup is expensive: it cannot use indexes efficiently on the joined collection in all cases. For high-frequency queries, consider embedding data at write time rather than joining at read time.

Pipeline $lookup (MongoDB 3.6+)

For complex join conditions, use the pipeline form:

javascript
{ $lookup: {
    from: "products",
    let: { productIds: "$items.productId" },
    pipeline: [
      { $match: { $expr: { $in: ["$_id", "$$productIds"] } } },
      { $project: { name: 1, price: 1 } }
    ],
    as: "productDetails"
}}

$addFields and $set

Add new fields or overwrite existing ones without re-specifying the whole document:

javascript
{ $addFields: {
  fullName: { $concat: ["$firstName", " ", "$lastName"] },
  ageGroup: {
    $switch: {
      branches: [
        { case: { $lt: ["$age", 18] }, then: "minor" },
        { case: { $lt: ["$age", 65] }, then: "adult" }
      ],
      default: "senior"
    }
  }
}}

$facet — Multi-Dimensional Analytics

$facet runs multiple sub-pipelines on the same input documents in a single pass:

javascript
db.products.aggregate([
  { $match: { status: "active" } },
  { $facet: {
    byBrand: [
      { $group: { _id: "$brand", count: { $sum: 1 } } },
      { $sort: { count: -1 } }
    ],
    priceDistribution: [
      { $bucket: {
          groupBy: "$price",
          boundaries: [0, 25, 50, 100, 200, 500],
          default: "500+",
          output: { count: { $sum: 1 }, avgPrice: { $avg: "$price" } }
      }}
    ],
    total: [{ $count: "count" }]
  }}
])

$bucket and $bucketAuto — Histogram Data

javascript
// Fixed price buckets
{ $bucket: {
  groupBy: "$price",
  boundaries: [0, 50, 100, 200, 500],
  default: "500+",
  output: { count: { $sum: 1 }, products: { $push: "$name" } }
}}

// Automatic equal-sized buckets
{ $bucketAuto: {
  groupBy: "$price",
  buckets: 5,
  output: { count: { $sum: 1 } }
}}

Real-World Pipeline: Monthly Revenue Report

javascript
db.orders.aggregate([
  // 1. Only confirmed orders from 2026
  { $match: {
    status: { $in: ["shipped", "delivered"] },
    createdAt: { $gte: ISODate("2026-01-01") }
  }},
  // 2. Group by year-month
  { $group: {
    _id: {
      year:  { $year: "$createdAt" },
      month: { $month: "$createdAt" }
    },
    revenue:    { $sum: "$amount" },
    orders:     { $sum: 1 },
    avgOrder:   { $avg: "$amount" }
  }},
  // 3. Sort chronologically
  { $sort: { "_id.year": 1, "_id.month": 1 } },
  // 4. Format the output
  { $project: {
    _id: 0,
    period: { $concat: [
      { $toString: "$_id.year" }, "-",
      { $toString: "$_id.month" }
    ]},
    revenue:  { $round: ["$revenue", 2] },
    orders:   1,
    avgOrder: { $round: ["$avgOrder", 2] }
  }}
])