Introduction
Arrays are everywhere in MongoDB documents—product variants, user preferences, order items, comment threads. But querying and transforming them in your pipelines can be tricky if you don't understand the right tools. We'll cover $unwind and array processing: what developers should know, showing you how to denormalize arrays into individual documents, filter and map over them in place, and avoid the common pitfalls that slow down production systems.
$unwind creates one document per element
The $unwind stage is the foundation of array processing in MongoDB aggregation pipelines. It takes a single document with an array field and produces n documents—one for each element in that array. This is useful when you need to group, match, or sort on individual array items rather than treating the array as a single value.
Let's say we have a product collection where each product has multiple reviews:
db.products.insertMany([
{
_id: 1,
name: "Laptop",
reviews: [
{ rating: 5, text: "Excellent!" },
{ rating: 4, text: "Good value" },
{ rating: 3, text: "Average" }
]
},
{
_id: 2,
name: "Mouse",
reviews: [
{ rating: 5, text: "Perfect" }
]
}
]);If you want to find the average rating across all reviews (not per product), you need to flatten the structure first:
db.products.aggregate([
{ $unwind: "$reviews" },
{ $group: {
_id: null,
avgRating: { $avg: "$reviews.rating" }
}}
]);After $unwind, the pipeline sees this intermediate state:
{ _id: 1, name: "Laptop", reviews: { rating: 5, text: "Excellent!" } }
{ _id: 1, name: "Laptop", reviews: { rating: 4, text: "Good value" } }
{ _id: 1, name: "Laptop", reviews: { rating: 3, text: "Average" } }
{ _id: 2, name: "Mouse", reviews: { rating: 5, text: "Perfect" } }Now $group can work on individual ratings. This pattern is essential for analytics, sorting by array metrics, and filtering on nested values. The cost is that $unwind multiplies document count in the pipeline, which can slow things down if your arrays are large.
preserveNullAndEmptyArrays keeps empty docs
By default, $unwind discards documents where the target field is missing, null, or an empty array. In many real-world scenarios, that loses important information. If you're analyzing user activity and some users have no events, removing those users from the pipeline breaks your counts and averages.
Set preserveNullAndEmptyArrays: true to keep those documents:
db.users.insertMany([
{
_id: 1,
username: "alice",
orders: [
{ orderId: 101, total: 50 },
{ orderId: 102, total: 75 }
]
},
{
_id: 2,
username: "bob",
orders: []
},
{
_id: 3,
username: "charlie",
orders: null
}
]);Without preserveNullAndEmptyArrays, you'd lose bob and charlie:
db.users.aggregate([
{ $unwind: "$orders" }
]);Result: only alice's two orders appear.
With the flag, all users stay in play:
db.users.aggregate([
{ $unwind: {
path: "$orders",
preserveNullAndEmptyArrays: true
}},
{ $group: {
_id: "$_id",
username: { $first: "$username" },
orderCount: { $sum: 1 },
totalSpent: { $sum: "$orders.total" }
}}
]);Result includes all three users, with bob and charlie showing 0 or null for orders. This is critical for reporting—you need to know who didn't buy, not just who did.
$filter and $map transform arrays in place
Sometimes you don't want to unwind at all. You just want to filter or transform an array within a document. That's where $filter and $map come in. They work directly on the array without creating extra documents.
$filter keeps elements that match a condition. Imagine you want to exclude low-rated reviews from a product document:
db.products.aggregate([
{ $project: {
name: 1,
reviews: {
$filter: {
input: "$reviews",
as: "review",
cond: { $gte: ["$$review.rating", 4] }
}
}
}}
]);Each product now has only reviews with rating ≥ 4. No unwinding, no extra documents, just cleaner data in the same shape.
$map transforms each element in place. Let's say you want to add a "helpful" percentage to each review based on upvotes:
db.products.aggregate([
{ $project: {
name: 1,
reviews: {
$map: {
input: "$reviews",
as: "review",
in: {
rating: "$$review.rating",
text: "$$review.text",
helpfulPercent: {
$multiply: [
{ $divide: ["$$review.upvotes", "$$review.totalVotes"] },
100
]
}
}
}
}
}}
]);You can also chain them—filter first, then map:
db.products.aggregate([
{ $project: {
name: 1,
topReviews: {
$map: {
input: {
$filter: {
input: "$reviews",
as: "r",
cond: { $gte: ["$$r.rating", 4] }
}
},
as: "r",
in: { rating: "$$r.rating", helpful: "$$r.upvotes" }
}
}
}}
]);These operations are efficient because they don't multiply documents. Use them whenever you're reshaping arrays within a single document context.
Avoid unwinding huge arrays
Unwinding is powerful, but it has real costs. Every element in the array creates a new document in the pipeline. If you have a product with 10,000 reviews and you unwind, suddenly that one document becomes 10,000 documents passing through every subsequent stage.
Consider a metrics pipeline that processes orders. Each order has items:
db.orders.insertMany([
{
_id: 1,
customerId: 101,
items: [
{ sku: "A1", qty: 5, price: 10 },
{ sku: "B2", qty: 2, price: 20 },
// ... potentially thousands of line items
]
}
]);If you unwind all items and then group by customerId, you're processing an enormous number of documents. Instead, compute what you need before unwinding, or avoid unwinding altogether:
// Better: compute itemCount and totalValue without unwinding
db.orders.aggregate([
{ $project: {
customerId: 1,
itemCount: { $size: "$items" },
totalValue: {
$reduce: {
input: "$items",
initialValue: 0,
in: { $add: ["$$value", { $multiply: ["$$this.qty", "$$this.price"] }] }
}
}
}},
{ $group: {
_id: "$customerId",
orderCount: { $sum: 1 },
avgItemCount: { $avg: "$itemCount" },
totalRevenue: { $sum: "$totalValue" }
}}
]);$reduce, $size, and $arrayElemAt give you the power to work with arrays without the document multiplication cost. Unwind only when you genuinely need to group or match on individual array elements, not when you just need to measure or aggregate the array itself.
Common mistakes
- Forgetting preserveNullAndEmptyArrays in reporting. Your user counts will be wrong if you drop users with no orders or no comments.
- Unwinding before filtering. If you unwind 1,000 items and then $match only 10, you've wasted pipeline work. Filter the array first with $filter, then unwind if needed.
- Not checking array size before unwinding in production. A user accidentally unwinding a field with 100K+ elements will cause timeouts. Add validation or use $limit to cap early.
- Confusing $map with $unwind. $map keeps the array shape; $unwind flattens it. Pick the right tool for the job.
- Nested arrays without clear intent. If you have arrays of arrays and need to process the inner level, $unwind twice will create a cartesian product. Use $reduce or explicit $project logic instead.
Key takeaways
- $unwind flattens arrays into individual documents—essential for aggregations that need to group or match on array elements.
- Set
preserveNullAndEmptyArrays: truein any reporting pipeline to avoid losing documents that have empty or null arrays. - $filter and $map work in place without multiplying documents; use them for reshaping and transforming arrays within single documents.
- Avoid unwinding large arrays unless you truly need per-element operations; use $reduce, $size, or other array operators instead.
- Test your pipelines with realistic data volumes and monitor performance—unwinding a million documents can lock up your database.






Comments
Loading comments…