$match and $project

$match and $project are the two most-used stages in the MongoDB aggregation pipeline. $match filters documents down to the ones you care about, and $project reshapes each surviving document — picking fields, renaming them, or computing new ones. Together they form the backbone of almost every aggregation pipeline you’ll write, and getting their placement right is the single biggest factor in whether your pipeline runs fast or scans an entire collection.

Overview / How it works

An aggregation pipeline is an array of stages passed to db.collection.aggregate(). MongoDB feeds documents through the stages in order: stage 1 processes the whole collection and passes its output documents to stage 2, stage 2 passes its output to stage 3, and so on. There is no single query optimizer decision made up front the way there is for find() — each stage does its work and hands a stream of documents to the next.

$match works like the query document you pass to find(): it uses the same query operators ($eq, $gt, $in, $regex, and so on) to keep only documents that satisfy a condition, discarding the rest. Crucially, if $match is the very first stage in the pipeline, MongoDB’s query planner can use an index to satisfy it, exactly as it would for a plain find() — it does not need to read every document. If $match appears later in the pipeline (after a $group or $project has already transformed the documents), it can no longer use an index, because the documents flowing into it are no longer the original indexed documents; MongoDB just filters the in-memory stream.

$project is different in kind: it doesn’t filter documents, it reshapes them. For every document that reaches it, $project produces a new document containing only the fields you specify, computed however you specify. This is where you drop fields you don’t need (reducing the data volume passed to later stages and back to your application), rename fields, and build new fields using aggregation expressions like $concat, $multiply, $size, or $round. $project never consults an index — it’s pure document transformation, run in memory for each document that reaches that stage.

Because of this asymmetry, the standard advice is: put $match as early as possible (ideally first, so it can use an index and shrink the working set before anything expensive happens), and use $project to trim documents down once you no longer need the fields you’re dropping. A pipeline that does $project then $match on a field you just dropped will fail outright, since that field no longer exists downstream.

Syntax

db.collection.aggregate([
  { $match: { <query conditions> } },
  { $project: { <field specifications> } }
]);

$match takes a single object using the same query operators as find():

  • field: value — exact equality, e.g. { status: "shipped" }
  • field: { $operator: value } — comparison/array/logical operators such as $gt, $gte, $lt, $in, $ne, $exists, $regex
  • $and / $or / $nor — combine multiple conditions explicitly when needed

$project takes an object mapping output field names to a specification:

  • 1 or true — include this field as-is, e.g. { customerName: 1 }
  • 0 or false — exclude this field. Only _id may be excluded ({ _id: 0 }) while other fields are included; you cannot mix inclusion and exclusion for non-_id fields in the same $project
  • an expression — compute a new value, e.g. { tax: { $multiply: ["$total", 0.08] } }. Field references inside expressions must be prefixed with $ (e.g. "$total"), otherwise MongoDB treats it as a literal string or the value is invalid
  • a new field name — you can rename by giving the output key a different name than the source, e.g. { customer: "$customerName" }

_id is included by default unless you explicitly set { _id: 0 } — this is the one exception to the inclusion/exclusion rule above.

Examples

Example 1: Filter and select fields. Given an orders collection, find all shipped orders and return only the customer name and total.

db.orders.aggregate([
  { $match: { status: "shipped" } },
  { $project: { _id: 0, customerName: 1, total: 1 } }
]);
[
  { customerName: "Priya Nair", total: 249.99 },
  { customerName: "Diego Ramos", total: 89.5 }
]

$match scans (or index-scans) the collection and keeps only documents where status equals "shipped". Each surviving document then passes through $project, which drops every field except customerName and total, and explicitly removes _id.

Example 2: Multiple conditions and a computed field. Find orders that are shipped or delivered with a total of at least 100, and add a computed tax field.

db.orders.aggregate([
  {
    $match: {
      status: { $in: ["shipped", "delivered"] },
      total: { $gte: 100 }
    }
  },
  {
    $project: {
      _id: 0,
      customer: "$customerName",
      total: 1,
      tax: { $round: [{ $multiply: ["$total", 0.08] }, 2] }
    }
  }
]);
[
  { total: 249.99, customer: "Priya Nair", tax: 20 },
  { total: 412.0, customer: "Amara Okafor", tax: 32.96 }
]

$match uses $in to accept either status value, combined with a $gte range on total — both conditions must be true (an implicit $and across an object’s fields). $project then renames customerName to customer and computes tax from total using nested expressions: $multiply runs first, then $round rounds the result to 2 decimal places.

Example 3: A realistic dashboard query. Get the top 5 highest-value delivered orders from the last 30 days, with an item count instead of the raw items array.

db.orders.aggregate([
  {
    $match: {
      status: "delivered",
      orderDate: { $gte: new Date("2026-07-04") }
    }
  },
  {
    $project: {
      _id: 0,
      customerName: 1,
      total: 1,
      region: 1,
      itemCount: { $size: "$items" }
    }
  },
  { $sort: { total: -1 } },
  { $limit: 5 }
]);
[
  { customerName: "Amara Okafor", total: 812.4, region: "EMEA", itemCount: 6 },
  { customerName: "Kenji Watanabe", total: 745.0, region: "APAC", itemCount: 3 }
  // ... up to 5 documents
]

The $match stage runs first and, because it’s first, can use a compound index on { status: 1, orderDate: 1 } to jump straight to the matching documents instead of scanning the whole collection. $project then strips the (potentially large) items array down to a single itemCount number using $size, which reduces how much data $sort and $limit have to move through the pipeline.

How it works step by step

For the pipeline in Example 3: MongoDB first checks whether an index covers the $match predicate. If { status: 1, orderDate: 1 } exists, the storage engine walks that index’s B-tree to find entries for status: "delivered" with orderDate on or after the given date, and fetches only those documents — this is an IXSCAN. Without a matching index, MongoDB performs a COLLSCAN, reading every document in the collection and testing each one against the match condition; you can confirm which happened by running .explain("executionStats") on the pipeline and checking the winningPlan for IXSCAN vs COLLSCAN.

Each document that survives $match is then handed, one at a time, to $project, which builds a brand-new document containing only the specified fields and computed expressions — the original document is not mutated, a fresh one is emitted. This new, smaller document stream is what $sort and $limit then operate on. Because $project already discarded items in favor of a single number, less memory and I/O is needed for the remaining stages than if the full array had been carried through the whole pipeline.

Common Mistakes

Mistake 1: Putting $match after expensive stages.

// Wrong: $group processes the ENTIRE collection before status is checked
db.orders.aggregate([
  { $group: { _id: "$region", total: { $sum: "$total" } } },
  { $match: { total: { $gt: 1000 } } }
]);

This isn’t wrong in the sense of producing incorrect results, but it’s slow: $group has to read and process every single document in the collection before $match ever runs, and no index can help because $group‘s output documents don’t exist in an index. Whenever you can filter on the original document’s fields, do it first:

// Better: filter status before grouping, if the business logic allows it
db.orders.aggregate([
  { $match: { status: "delivered" } },
  { $group: { _id: "$region", total: { $sum: "$total" } } },
  { $match: { total: { $gt: 1000 } } }
]);

Note the second $match (filtering on the grouped total) is unavoidable after $group, since that value doesn’t exist until grouping happens — but the first $match still shrinks the input to $group and can use an index.

Mistake 2: Forgetting the $ prefix on field references in $project.

// Wrong: 'total' is not a JS variable, and even as a string it's not what's intended
db.orders.aggregate([
  { $project: { grandTotal: total } }
]);

This throws a ReferenceError in mongosh because total is being evaluated as a bare JavaScript identifier, not a field reference. Field references inside aggregation expressions must be quoted strings prefixed with $:

db.orders.aggregate([
  { $project: { grandTotal: "$total" } }
]);

Mistake 3: Mixing inclusion and exclusion in $project.

// Wrong: cannot include customerName while excluding internalNotes in the same $project
db.orders.aggregate([
  { $project: { customerName: 1, internalNotes: 0 } }
]);

MongoDB throws an error here (aside from the _id exception). Pick one mode — either list the fields you want (inclusion) or list the fields you don’t want (exclusion):

db.orders.aggregate([
  { $project: { customerName: 1, total: 1, status: 1 } }
]);

Best Practices

  • Place $match as early as possible in the pipeline — ideally as the first stage — so it can use an index and reduce the document count before any heavier stage runs.
  • Follow the equality-sort-range (ESR) rule when building a compound index to support a $match: equality fields first, then sort fields, then range fields.
  • Use .explain("executionStats") on any pipeline touching a large collection to confirm you get IXSCAN, not COLLSCAN.
  • Use $project to drop large or unneeded fields (arrays, long text) as early as reasonably possible, so less data flows through $sort, $group, or $lookup later in the pipeline.
  • Prefer a second, later $match to filter on values computed by an earlier stage (like a $group total) — that’s the one case where $match can’t run first.
  • Remember $project is not the only way to shape output — $addFields keeps all existing fields and adds new ones, which is often clearer than re-listing every field in $project when you just want to add one computed field.

Practice Exercises

  • Given a products collection with fields name, category, price, and inStock, write a pipeline that returns the name and price (no _id) of all in-stock products priced under 50.
  • Using the same orders collection from the examples, write a pipeline that matches orders from the "APAC" region and projects a new field totalWithShipping equal to total plus a flat 15.
  • Explain in one sentence why running .explain("executionStats") on db.orders.aggregate([{ $project: { status: 1 } }, { $match: { status: "shipped" } }]) would show a COLLSCAN even if an index on status exists.

Summary

  • $match filters documents using the same query operators as find(), and can use an index only when it’s the first stage in the pipeline.
  • $project reshapes each document: include fields with 1, exclude with 0 (only _id can be excluded alongside inclusions), rename, or compute new fields with expressions.
  • Field references inside $project expressions must be prefixed with $, e.g. "$total".
  • Put $match as early as possible to shrink the working set before expensive stages like $group, $sort, or $lookup.
  • Use .explain("executionStats") to verify IXSCAN vs COLLSCAN whenever performance matters.