The Query You Actually Want to Write

You need to group documents by date, filter out inactive users, compute an average per group, and sort by that average. One query. That is what the aggregation pipeline is for.

If your application pulls thousands of documents over the network just to filter and sum them in JavaScript, you are doing the database's job in your application code. The aggregation pipeline pushes that work to MongoDB itself, so only the result crosses the network. On large collections that difference is usually the biggest win you can get.

This covers the three stages everyone uses ($match, $group, $sort), then the three that are actually interesting ($lookup, $unwind, $facet). Plus $setWindowFields because window functions are absurdly useful once you know they exist.

Pipeline Basics: How Stages Flow

Array of stage objects. Pass it to aggregate(). Output of stage one becomes input of stage two.

JavaScript (MongoDB Shell)
// A basic aggregation pipeline with two stages
db.orders.aggregate([
  // Stage 1: Filter documents
  { $match: { status: "completed" } },

  // Stage 2: Shape the output
  { $project: {
    customerName: 1,
    total: 1,
    orderDate: 1,
    _id: 0
  }}
]);

Order matters. $match first means fewer documents for every downstream stage. Stages can repeat — $match early, group, $match again on the grouped results. And the pipeline never modifies your collection. Returns a cursor, same as find().

Filtering with $match and $project

$match uses the same query syntax as find(). Same operators. Same patterns. Nothing new to learn.

$project is where it gets interesting. Not just showing and hiding fields — computed fields, renames, inline expressions. Here is a products collection query that filters in-stock electronics and adds a formatted price label:

JavaScript
db.products.aggregate([
  // Only in-stock electronics
  { $match: {
    category: "Electronics",
    stock: { $gt: 0 }
  }},

  // Reshape the output with computed fields
  { $project: {
    _id: 0,
    name: 1,
    priceLabel: {
      $concat: [{ $literal: "$" }, { $toString: "$price" }]
    },
    discountedPrice: {
      $round: [
        { $multiply: ["$price", 0.9] },
        2
      ]
    },
    inStock: "$stock"
  }}
]);

priceLabel concatenates a dollar sign with the price. The $literal matters: any string that starts with $ is read as a field path, so a bare "$" makes the stage fail. discountedPrice applies a 10% discount and rounds to two decimals. All inside the database. Your application code receives the finished result.

Want computed fields without dropping existing ones? Use $addFields (aliased as $set) instead of $project. Keeps every field, appends the new ones.

Grouping and Accumulating with $group

SQL's GROUP BY with SUM(), AVG(), COUNT(). Monthly sales summary:

JavaScript
db.orders.aggregate([
  { $match: {
    status: "completed",
    orderDate: {
      $gte: ISODate("2026-01-01"),
      $lt: ISODate("2027-01-01")
    }
  }},
  { $group: {
    _id: {
      year: { $year: "$orderDate" },
      month: { $month: "$orderDate" }
    },
    totalRevenue: { $sum: "$total" },
    averageOrderValue: { $avg: "$total" },
    orderCount: { $sum: 1 },
    largestOrder: { $max: "$total" }
  }},

  // Sort by year and month ascending
  { $sort: { "_id.year": 1, "_id.month": 1 } }
]);
mongosh output of the monthly $group pipeline: documents with _id year 2026 and month 1 and 2, totalRevenue, averageOrderValue, orderCount and largestOrder.
Example output. Each output document is one month: the grouped _id plus the four accumulators, sorted oldest first.

Accumulators worth knowing: $sum (set to 1 to count documents), $avg, $min/$max, $first/$last (useful after a $sort), $push (collects into array), $addToSet (unique values only).

_id: null treats everything as one group. Global totals across whatever survived the $match.

Joining Collections with $lookup

$lookup is basically a join. But worse in specific ways.

It is a left outer join only. Same database only. Cannot join across databases. The syntax requires four fields and you will forget which is which for the first dozen times: from (the other collection), localField (field in your current documents), foreignField (matching field in the foreign collection), as (name for the resulting array). Yes, array — it always produces an array, even for one-to-one relationships. You almost always need $unwind right after it to flatten that array into a single object.

But be careful. If a reference is broken and the array is empty, $unwind drops the entire document. Add preserveNullAndEmptyArrays: true if you need to keep unmatched documents.

Orders enriched with customer name and email:

JavaScript
db.orders.aggregate([
  { $match: { status: "completed" } },

  // Join with the customers collection
  { $lookup: {
    from: "customers",
    localField: "customerId",
    foreignField: "_id",
    as: "customerInfo"
  }},

  // $lookup returns an array; unwind to get a single object
  { $unwind: "$customerInfo" },

  // Clean up the output
  { $project: {
    orderId: "$_id",
    total: 1,
    orderDate: 1,
    customerName: "$customerInfo.name",
    customerEmail: "$customerInfo.email",
    _id: 0
  }}
]);
mongosh output of the $lookup pipeline: order documents with total, orderDate, orderId, customerName and customerEmail fields.
Example output. After $unwind each order carries a single customer's name and email instead of a customerInfo array.

For more complex join logic, the pipeline form of $lookup. Instead of simple field matching, you define a sub-pipeline that filters, projects, and sorts the joined documents before they get embedded:

JavaScript
// Pipeline form of $lookup for complex joins
db.orders.aggregate([
  { $lookup: {
    from: "reviews",
    let: { orderId: "$_id" },
    pipeline: [
      { $match: {
        $expr: {
          $and: [
            { $eq: ["$orderId", "$$orderId"] },
            { $gte: ["$rating", 4] }
          ]
        }
      }},
      { $project: { rating: 1, comment: 1, _id: 0 } }
    ],
    as: "positiveReviews"
  }}
]);

let passes variables from the outer pipeline into the sub-pipeline. Especially useful when the foreign collection is large — narrow the join results before they get embedded instead of pulling every matching document and filtering afterward. On a collection with millions of reviews, the difference between the simple form and the pipeline form can be the difference between a query that finishes and one that times out.

Array Operations: $unwind, $addToSet, $push

MongoDB documents are full of arrays. Order items, tags, roles, activity logs.

$unwind explodes an array field into one document per element. Five items in the array? Five documents out. Then you can group, sort, and aggregate on individual elements.

Top-selling products across all orders, starting from a lineItems array:

JavaScript
db.orders.aggregate([
  { $match: { status: "completed" } },

  // Flatten the lineItems array
  { $unwind: "$lineItems" },

  // Group by product and calculate totals
  { $group: {
    _id: "$lineItems.productId",
    productName: { $first: "$lineItems.name" },
    totalQuantity: { $sum: "$lineItems.quantity" },
    totalRevenue: {
      $sum: {
        $multiply: [
          "$lineItems.price",
          "$lineItems.quantity"
        ]
      }
    },
    orderIds: { $addToSet: "$_id" }
  }},

  // Sort by revenue descending
  { $sort: { totalRevenue: -1 } },

  // Top 10 products
  { $limit: 10 }
]);

$push vs $addToSet: push keeps duplicates, addToSet does not. Use addToSet for distinct order IDs, unique tags. Use push when duplicates matter — timestamps of repeated events, for instance.

Sometimes you do not want to flatten an array at all. $filter trims array elements in place:

JavaScript
// Keep only line items above $50 without unwinding
db.orders.aggregate([
  { $project: {
    customerName: 1,
    orderDate: 1,
    expensiveItems: {
      $filter: {
        input: "$lineItems",
        as: "item",
        cond: { $gt: ["$$item.price", 50] }
      }
    }
  }}
]);

Document structure stays intact. Fewer elements in the array, nothing else changes.

Window Functions and $setWindowFields

Since MongoDB 5.0. The key difference from $group: window functions keep every document. They add computed fields without collapsing rows.

Running totals, moving averages, rankings. All without losing individual documents.

JavaScript
db.orders.aggregate([
  { $match: { status: "completed" } },
  { $setWindowFields: {
    partitionBy: "$customerId",
    sortBy: { orderDate: 1 },
    output: {
      runningTotal: {
        $sum: "$total",
        window: {
          documents: ["unbounded", "current"]
        }
      },
      orderRank: {
        $rank: {}
      },
      movingAvg: {
        $avg: "$total",
        window: {
          documents: [-2, 0]
        }
      }
    }
  }}
]);

partitionBy groups by customer. Within each partition, documents sort by date.

runningTotal sums from the first document to the current one. Cumulative total per customer. orderRank assigns a rank within each partition. movingAvg averages the current document and the two before it — a 3-order moving average from the [-2, 0] window.

Every original document survives. New fields just appear alongside the existing ones. And if you have been reaching for application code to compute running totals or rolling averages, stop. The database does this faster, and the logic stays where the data lives instead of scattered across your service layer.

Performance Optimization

A correct pipeline that takes 30 seconds is not finished. Works fine on test data, falls apart on production volumes. Every time.

Index Your First $match

The pipeline itself can only use an index for the stages at the start: the initial $match and a $sort right after it. Once documents have been reshaped, later stages work on what the earlier ones passed down. $lookup is different because it queries a second collection, which is why its foreignField needs an index of its own (below). If your first stage filters on { status: "completed", orderDate: { $gte: ... } }, you need a compound index on { status: 1, orderDate: 1 }. Verify with .explain("executionStats"). No index on the first $match means a full collection scan before the pipeline even starts.

Limit Early, Project Early

Do not push a million documents through $group and $lookup only to take the top 10 at the end. Move $limit up. Use $project early to drop fields. Fewer fields means less memory at every stage.

Know Where allowDiskUse Applies

Each blocking stage, such as $group, $sort without an index, $bucket or $setWindowFields, can use 100 MB of RAM. Since MongoDB 6.0 the server parameter allowDiskUseByDefault is true, so those stages write temporary files to disk instead of failing. On older servers, or where an administrator has turned that default off, pass the option yourself:

JavaScript
// Needed on MongoDB 5.x, or when allowDiskUseByDefault is false
db.orders.aggregate([
  { $match: { status: "completed" } },
  { $group: {
    _id: "$productCategory",
    totalRevenue: { $sum: "$total" },
    allCustomers: { $addToSet: "$customerId" }
  }},
  { $sort: { totalRevenue: -1 } }
], { allowDiskUse: true });

Spilling keeps the query alive, but disk is much slower than memory. If a pipeline spills regularly, filter or project earlier so the blocking stage sees fewer, smaller documents. $facet is the exception: it cannot spill at all.

Index the $lookup Foreign Field

$lookup on an unindexed foreign collection is a collection scan per document. Millions of documents, no index? Every single join does a full scan. Always index the foreignField. It is an easy one to miss, because the pipeline works fine on small test data.

Materialized Views with $merge

Same expensive pipeline running on every dashboard load? Write the results to a separate collection with $merge. Query that collection directly. Rerun the pipeline when source data changes. $out works too but replaces the entire output collection. $merge can upsert.

Profile First

.explain("executionStats"). Check executionTimeMillis and totalDocsExamined. If docs examined far exceeds docs returned, you need a better index or an earlier $match. Do not guess. The explain output tells you exactly which stages are expensive.

$facet: Multiple Pipelines in One Query

$facet sends the same input documents through several independent sub-pipelines and returns one document with a field for each. The documents are read once, before the stage, so a dashboard that needs three summaries can get them in a single round trip.

JavaScript
// One query for a dashboard: totals, top products and orders per region
db.orders.aggregate([
  // Runs first and can use the { status: 1, orderDate: 1 } index
  { $match: {
    status: "completed",
    orderDate: { $gte: ISODate("2026-01-01") }
  }},
  { $facet: {
    summary: [
      { $group: { _id: null, revenue: { $sum: "$total" }, orders: { $sum: 1 } } },
      { $project: { _id: 0 } }
    ],
    topProducts: [
      { $unwind: "$items" },
      { $group: { _id: "$items.productId", sold: { $sum: "$items.quantity" } } },
      { $sort: { sold: -1 } },
      { $limit: 5 }
    ],
    byRegion: [
      { $sortByCount: "$shippingAddress.region" }
    ]
  }}
]);

// Result: one document
// { summary: [{ revenue: ..., orders: ... }], topProducts: [...], byRegion: [...] }
mongosh output of the $facet pipeline: one document with summary revenue 160500.5 and 1347 orders, five topProducts with sold counts, and byRegion counts for four regions.
Example output. $facet returns a single document with one field per sub-pipeline.

You define named facets, each containing its own pipeline stages. One facet computes total revenue. Another computes top products. A third computes customer counts by region. All of them run against the same input documents, all in a single round trip to the database. The result is one document with one field per facet.

Most teams discover $facet after building three separate aggregation queries for a dashboard and realizing they could be one. If your API handler runs multiple aggregations against the same collection with overlapping $match conditions, $facet collapses them into a single query. Network round trips drop, and the shared $match runs once instead of three times.

Three limits to know before you rely on it:

You also cannot use $out, $merge, $geoNear or another $facet inside a facet.

AS

Anurag Sinha

Founder & Lead Developer

Anurag is a full stack developer and self-taught programmer from India with over five years of experience building web applications, APIs, and cloud-native systems. He researches, writes and reviews every tutorial on Codertronix, and updates them when the tools change.