Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 020 / 100

$unwind and array processing: what developers should know

Work with arrays in pipelines.

Expona Admin7 min read

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:

javascript
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:

javascript
db.products.aggregate([
  { $unwind: "$reviews" },
  { $group: {
    _id: null,
    avgRating: { $avg: "$reviews.rating" }
  }}
]);

After $unwind, the pipeline sees this intermediate state:

javascript
{ _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:

javascript
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:

javascript
db.users.aggregate([
  { $unwind: "$orders" }
]);

Result: only alice's two orders appear.

With the flag, all users stay in play:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
// 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: true in 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles