When you work with MongoDB in Node.js, sooner or later you face data that won't be extracted with a simple find(). Orders grouped by month, average basket per category, inventory reports with joins across collections. Plain queries aren't enough. You need the aggregation pipeline. And with Mongoose, schemas become clean and maintainable, but the pipeline has its own rules. At Meteora Web, we've built e‑commerce platforms and ERP systems precisely on this pair: Mongoose for data modeling, aggregation for querying without roundtripping. In this guide we'll see how to use them together, avoiding the pitfalls that slow down code or hurt readability.
Why use Mongoose ODM and aggregation pipeline instead of raw queries?
Mongoose is not just an ODM: it adds validation, automatic casting, middleware (pre/post save), and a fluent query language. With the aggregation pipeline, though, you are not forced to write manual BSON like the native driver. You can use Mongoose's Model.aggregate() which accepts an array of stages identical to the driver's, but you still benefit from typing and virtuals. Moreover, Mongoose can automatically convert types in the results (e.g., _id from ObjectId to string) if configured. This was crucial in projects where data had to go straight to a Vue frontend: no manual mappings.
How to set up Mongoose for the aggregation pipeline?
First, define a schema. You don't need to over‑engineer it – even a minimal schema is better than none, because Mongoose applies validation rules during updates. Here's an example for an order model:
Sponsored Protocol
const mongoose = require('mongoose');
const orderSchema = new mongoose.Schema({
customerId: { type: mongoose.Schema.Types.ObjectId, ref: 'Customer' },
items: [{
productId: { type: mongoose.Schema.Types.ObjectId, ref: 'Product' },
qty: Number,
price: Number
}],
status: { type: String, enum: ['pending', 'shipped', 'cancelled'] },
createdAt: { type: Date, default: Date.now }
}, { timestamps: true });
module.exports = mongoose.model('Order', orderSchema);
Now you can use Order.aggregate(). Note: Mongoose does not automatically apply the indexes defined in the schema when you use aggregate(); you must create them manually on MongoDB. This is a common mistake that causes slow queries.
Practical example: monthly revenue report with $match, $group, $project
Imagine you need to calculate the monthly revenue for the last year, with average order value and order count. A find() query would force you to download all documents and process them in Node.js – slow. With the aggregation pipeline you do it on the database side.
const result = await Order.aggregate([
{ $match: { createdAt: { $gte: new Date('2025-01-01') }, status: 'shipped' } },
{
$group: {
_id: { $dateToString: { format: '%Y-%m', date: '$createdAt' } },
totalRevenue: { $sum: { $reduce: { input: '$items', initialValue: 0, in: { $add: ['$$value', { $multiply: ['$$this.qty', '$$this.price'] }] } } } },
avgOrderValue: { $avg: { $reduce: { input: '$items', initialValue: 0, in: { $add: ['$$value', { $multiply: ['$$this.qty', '$$this.price'] }] } } } },
orderCount: { $sum: 1 }
}
},
{ $sort: { _id: 1 } },
{ $project: { month: '$_id', totalRevenue: 1, avgOrderValue: 1, orderCount: 1, _id: 0 } }
]);
Result: an array of objects with month, totalRevenue, avgOrderValue, orderCount. Zero post‑processing. It works on huge collections if stages are indexed. Notice we used $reduce to calculate the subtotal of each order – no lookup to a separate collection, everything inline.
Sponsored Protocol
How to handle $lookup with Mongoose for joining collections?
One of MongoDB's strengths is embedding, but sometimes you need joins. With Mongoose, you can use $lookup directly in the pipeline. The result, however, does not automatically follow the Mongoose schema – you must specify the fields. Example: a report showing for each order the customer name and total.
const report = await Order.aggregate([
{ $match: { createdAt: { $gte: startDate } } },
{
$lookup: {
from: 'customers', // collection name (by default pluralized model)
localField: 'customerId',
foreignField: '_id',
as: 'customer'
}
},
{ $unwind: '$customer' },
{
$addFields: {
totalOrder: { $sum: '$items.price' } // simplified example
}
},
{ $project: { 'customer.name': 1, totalOrder: 1, createdAt: 1 } }
]);
Caution: If the collection has many documents, $lookup can be slow. To optimize, create an index on customerId in the orders collection and on _id in customers (already indexed by default). Also, $unwind duplicates documents: if an order had multiple customers (rare), it would generate duplicates. Use $unwind only when the relationship is one‑to‑one.
Sponsored Protocol
What are the most common mistakes with Mongoose and aggregation pipeline?
1. Forgetting that Mongoose does not automatically convert types in aggregation results. The _id fields are ObjectId, not strings. If you pass them to a frontend JavaScript, you must call toString() or use toJSON() with mongoose.set('toJSON', { getters: true, virtuals: true }) but it doesn't always work with aggregate. Solution: add a $project or $addFields stage to explicitly convert _id: { $toString: '$_id' }.
2. Not using allowDiskUse() for heavy pipelines. MongoDB by default uses 100 MB of RAM for aggregation operations. If the aggregation exceeds this limit, it returns an error. Append .allowDiskUse(true) at the end of the pipeline when working with large datasets.
Sponsored Protocol
const result = await Order.aggregate([...]).allowDiskUse(true);
3. Creating unnecessary stages. Each stage adds overhead. If you can do everything in one $group instead of two, do it. A final $project to remove fields is often unnecessary if you already did it inside $group.
How to optimize the pipeline for real performance?
The rules are few but precise:
- Filter first: Put
$matchas early as possible, ideally as the first stage. It reduces the number of documents going into subsequent stages. - Use indexes on every field in
$matchand$sort. Create a compound index for the most common filters (e.g.,{ createdAt: -1, status: 1 }). - Avoid
$lookupwhen you can use embedding. If data is always read together, consider putting it directly in the document. - Project early, not only at the end. Fewer fields → less memory.
- Use
$matchafter$lookupto filter on fields of the joined collection only if strictly necessary, because$lookupalready loads all matching documents.
Which Mongoose schema works best for aggregation?
The flatter and more typed the schema, the better. Avoid deeply nested fields – having to use multiple $unwind stages slows down the pipeline. If you have arrays of objects, ensure they are homogeneous. Always include a createdAt field with type Date and default Date.now – it is the most common field used in temporal $match. For ObjectId references, use ref for readability but remember that aggregation does not use it automatically; you must write $lookup manually.
Sponsored Protocol
What to do now
1. Take the order model above and test it in a local environment. Create 1000 test documents and run the monthly revenue pipeline. Measure time with explain().
const exp = await Order.aggregate([...]).explain('executionStats');
console.log(exp.executionStats);
2. Add allowDiskUse(true) to the pipeline if you work with datasets larger than 100 MB.
3. Check the indexes on your actual collection: db.orders.getIndexes() and verify that the fields used in $match are indexed.
4. If you have never used $lookup, write a query that joins orders and customers and compare times with Mongoose's populate() (which runs separate queries). You will discover that for simple joins populate() may suffice; for complex reports aggregation wins.
We at Meteora Web use this combination daily for e‑commerce dashboards and ERP systems. If you want to dive deeper into using Node.js for backends, start from our pillar. The rest is practice, measurement, and optimization.