Type something to search...
Working with Arrays in MongoDB: Queries, Updates, and $elemMatch

Working with Arrays in MongoDB: Queries, Updates, and $elemMatch

Arrays are one of the best reasons to use MongoDB. Tags, line items, addresses, sizes in stock: data that would need a join table in SQL can live right inside the document it belongs to. You read the whole thing in one go and the shape of your data matches the shape of your code.

Then you write your first real query against an array of objects and get back documents you didn't expect. You asked for orders with an item that's a mug and costs more than 20, and MongoDB returns an order with a cheap mug and an expensive lamp. That's not a bug. It's how array matching works, and once you understand the rule, $elemMatch stops being mysterious.

This guide covers how MongoDB matches arrays, the operators built for them ($all, $size, $elemMatch), projecting array contents, updating specific elements with positional operators and arrayFilters, indexing arrays, and the design limits worth knowing.

Sample Data

The examples use a small orders collection. Paste this into mongosh to follow along:

db.orders.insertMany([
  {
    _id: 1,
    customer: "ada",
    tags: ["gift", "express"],
    items: [
      { sku: "MUG-01", name: "Mug", qty: 2, price: 12 },
      { sku: "LAMP-04", name: "Lamp", qty: 1, price: 65 },
    ],
  },
  {
    _id: 2,
    customer: "grace",
    tags: ["express"],
    items: [{ sku: "MUG-01", name: "Mug", qty: 1, price: 24 }],
  },
  {
    _id: 3,
    customer: "linus",
    tags: ["gift", "bulk", "express"],
    items: [
      { sku: "PEN-10", name: "Pen", qty: 50, price: 1 },
      { sku: "PAD-02", name: "Notepad", qty: 10, price: 4 },
    ],
  },
]);

How MongoDB Matches Arrays

The core rule: when a field holds an array, a query condition on that field matches if any element satisfies it. MongoDB effectively "reaches into" the array for you.

db.orders.find({ tags: "gift" });
// returns orders 1 and 3

You didn't have to say "tags contains gift". An equality condition on an array field is a containment check by default.

Exact Array Matches

If you pass an array as the value, you're asking for an exact match: same elements, same order.

db.orders.find({ tags: ["gift", "express"] }); // order 1 only
db.orders.find({ tags: ["express", "gift"] }); // nothing

That's rarely what you want. If order shouldn't matter, use $all:

db.orders.find({ tags: { $all: ["express", "gift"] } });
// orders 1 and 3: both contain both tags, in any order, possibly with others

Matching by Size

$size matches arrays with an exact number of elements:

db.orders.find({ items: { $size: 1 } }); // order 2

$size doesn't accept ranges, and it can't use an index. If you frequently query "orders with more than 3 items", maintain an itemCount field alongside the array (increment it in the same update that pushes an item) and index that instead. To check for a non-empty array, { "items.0": { $exists: true } } is a neat trick: it asks whether the first element exists.

Matching by Position

You can target a specific index with dot notation:

db.orders.find({ "tags.0": "gift" }); // orders whose first tag is "gift"

This is fine for a queue-like array where position means something. For most data, position is incidental, so be cautious about relying on it.

Querying Arrays of Documents

Dot notation reaches into array elements, too. "items.sku" means "the sku field of any element in items":

db.orders.find({ "items.sku": "MUG-01" }); // orders 1 and 2

Now the trap. Suppose you want orders containing a mug that cost more than 20:

db.orders.find({ "items.name": "Mug", "items.price": { $gt: 20 } });
// returns orders 1 AND 2

Order 1's mug costs 12. Why did it match? Because each condition is evaluated independently against the array. "Some element has name Mug" is true (the mug). "Some element has price greater than 20" is also true (the lamp). Both conditions pass, so the document matches, even though no single element satisfies both.

$elemMatch: Conditions on the Same Element

$elemMatch says "at least one element must satisfy all of these conditions together":

db.orders.find({
  items: { $elemMatch: { name: "Mug", price: { $gt: 20 } } },
});
// order 2 only

Rule of thumb: if you have more than one condition on fields of the same array element, you need $elemMatch. One condition on an array of documents doesn't need it; "items.sku": "MUG-01" is fine on its own.

$elemMatch on Scalar Arrays

The same issue shows up with ranges on arrays of numbers. Consider a readings field:

db.sensors.insertMany([
  { _id: "a", readings: [5, 30] },
  { _id: "b", readings: [15] },
]);

db.sensors.find({ readings: { $gt: 10, $lt: 20 } });
// returns a AND b

Sensor a has no reading between 10 and 20, but 30 satisfies $gt: 10 and 5 satisfies $lt: 20. Use $elemMatch to require one element in the range:

db.sensors.find({ readings: { $elemMatch: { $gt: 10, $lt: 20 } } });
// returns b only

Negation Is Different

Negated conditions flip the "any element" logic in ways that surprise people:

db.orders.find({ tags: { $ne: "gift" } });
// order 2: $ne means NO element equals "gift"

db.orders.find({ items: { $not: { $elemMatch: { price: { $lt: 5 } } } } });
// orders 1 and 2: no item cheaper than 5

$ne and $nin on an array field mean "none of the elements match", not "some element doesn't match". And like other negations, they can't use indexes efficiently, so pair them with a selective positive condition when possible.

Projecting Array Contents

Often you only want part of an array back. There are three tools for that.

$slice returns a subset by position:

db.orders.find({ _id: 3 }, { items: { $slice: 1 } });
// first item only; use -1 for the last, or [skip, limit]

The positional $ projection returns the first element that matched the query:

db.orders.find({ "items.sku": "LAMP-04" }, { customer: 1, "items.$": 1 });
// { _id: 1, customer: "ada", items: [ { sku: "LAMP-04", ... } ] }

$elemMatch in a projection returns the first element matching its own condition, independent of the query:

db.orders.find(
  {},
  { customer: 1, items: { $elemMatch: { qty: { $gte: 10 } } } },
);

All three return at most one element (or one slice). If you need every matching element, use $filter in an aggregation pipeline or a find projection expression:

db.orders.find(
  {},
  {
    customer: 1,
    bigItems: {
      $filter: { input: "$items", as: "i", cond: { $gte: ["$$i.qty", 10] } },
    },
  },
);
[
  { _id: 1, customer: "ada", bigItems: [] },
  { _id: 2, customer: "grace", bigItems: [] },
  {
    _id: 3,
    customer: "linus",
    bigItems: [
      { sku: "PEN-10", name: "Pen", qty: 50, price: 1 },
      { sku: "PAD-02", name: "Notepad", qty: 10, price: 4 },
    ],
  },
];

For more on shaping results, see the guide to MongoDB projection.

Adding and Removing Elements

The array update operators each deserve a quick refresher, since they're the everyday tools:

// append one item
db.orders.updateOne(
  { _id: 2 },
  { $push: { items: { sku: "PEN-10", name: "Pen", qty: 5, price: 1 } } },
);

// add tags only if they're not already present
db.orders.updateOne(
  { _id: 2 },
  { $addToSet: { tags: { $each: ["gift", "express"] } } },
);

// remove any item with qty 0
db.orders.updateMany({}, { $pull: { items: { qty: 0 } } });

// drop the last tag
db.orders.updateOne({ _id: 3 }, { $pop: { tags: 1 } });

$push also supports $each with $sort and $slice to keep a capped, ordered list. These operators are covered in depth in MongoDB update operators, so this section stays brief.

One common need: "add the item if it isn't in the cart, otherwise increase its quantity". There's no single operator for that, but you can do it with two conditional updates, or with a pipeline update:

const newItem = { sku: "MUG-01", name: "Mug", qty: 1, price: 12 };

db.orders.updateOne({ _id: 3 }, [
  {
    $set: {
      items: {
        $cond: [
          { $in: [newItem.sku, "$items.sku"] },
          {
            $map: {
              input: "$items",
              as: "i",
              in: {
                $cond: [
                  { $eq: ["$$i.sku", newItem.sku] },
                  {
                    $mergeObjects: [
                      "$$i",
                      { qty: { $add: ["$$i.qty", newItem.qty] } },
                    ],
                  },
                  "$$i",
                ],
              },
            },
          },
          { $concatArrays: ["$items", [newItem]] },
        ],
      },
    },
  },
]);

It's verbose, but it's a single atomic write. For most apps, the two-update approach (try $inc on the matched element, and $push only if that matched nothing) is easier to read and perfectly correct as long as the $push filter excludes carts that already contain the SKU.

Updating Specific Elements

The Positional $ Operator

$ refers to the first array element matched by the query filter:

db.orders.updateOne(
  { _id: 1, "items.sku": "MUG-01" },
  { $set: { "items.$.price": 14 } },
);

The array field must appear in the filter, otherwise MongoDB doesn't know which element $ stands for. With multiple conditions on the element, use $elemMatch in the filter so $ points to the element that satisfies all of them:

db.orders.updateOne(
  { _id: 1, items: { $elemMatch: { sku: "MUG-01", qty: { $gte: 2 } } } },
  { $inc: { "items.$.qty": -1 } },
);

$ only updates one element, the first match.

All Elements with $[]

$[] updates every element in the array:

db.orders.updateMany({}, { $set: { "items.$[].currency": "GBP" } });

Filtered Elements with arrayFilters

$[identifier] combined with arrayFilters updates every element that meets a condition, which $ can't do:

db.orders.updateMany(
  {},
  { $mul: { "items.$[cheap].price": 1.1 } },
  { arrayFilters: [{ "cheap.price": { $lt: 5 } }] },
);

The identifier must start with a lowercase letter and contain only alphanumerics. You can nest identifiers to reach arrays inside arrays:

// structure: { warehouses: [ { name, bins: [ { sku, qty } ] } ] }
db.stock.updateOne(
  { _id: "uk" },
  { $inc: { "warehouses.$[w].bins.$[b].qty": 10 } },
  {
    arrayFilters: [{ "w.name": "leeds" }, { "b.sku": "MUG-01" }],
  },
);

Here's how the three compare:

GoalUse
Update the one element the filter hititems.$
Update every elementitems.$[]
Update every element matching a ruleitems.$[x] with arrayFilters

Indexing Arrays

When you index a field that contains arrays, MongoDB creates a multikey index automatically: one index entry per element. This makes containment queries like { tags: "gift" } and { "items.sku": "MUG-01" } fast.

db.orders.createIndex({ "items.sku": 1, "items.qty": 1 });

A few rules to keep in mind:

  • A compound index can include at most one array field per document. If both tags and items were in the same compound index and a document had arrays in both, the insert would fail.
  • Multikey indexes grow with array length. An array of 5,000 elements means 5,000 index entries for that document.
  • $elemMatch queries can use compound multikey indexes on sub-fields of the same array, and the planner can combine bounds on both fields when the conditions sit inside the same $elemMatch.

Use explain("executionStats") to confirm the index is used and check isMultiKey: true in the plan.

Design Limits for Arrays

Arrays shine for bounded, related data that you read together with the parent. They struggle when they grow forever. Watch for these signals:

  • The array can grow without a clear upper bound (comments, events, log entries).
  • You mostly need a small slice of it, but you load the whole document every time.
  • Many clients update the same document's array concurrently, causing contention.
  • The document is heading toward the 16 MB BSON limit.

When those show up, move the elements to their own collection with a reference back to the parent. The one-to-many relationships guide covers when to embed and when to reference.

Common Pitfalls

Using multiple dot-notation conditions instead of $elemMatch. Conditions on "items.name" and "items.price" can be satisfied by different elements. If they must apply to the same element, wrap them in $elemMatch.

Expecting exact-match semantics from $all. $all checks containment, not equality. An array with extra values still matches. Combine with $size if you need exactly those elements.

Forgetting the array field in the filter when using $. The positional operator needs the query to identify an element. Without it, the update errors out.

Expecting $ to update every match. It updates the first one only. Use $[identifier] with arrayFilters for all matches.

Pushing without bounds. Use $slice with $push, or move unbounded lists into their own collection.

Conclusion

Arrays in MongoDB follow one simple rule that explains nearly every surprise: a condition on an array field matches if any element satisfies it, and separate conditions can be satisfied by separate elements. $elemMatch ties conditions to a single element, $all and $size handle whole-array checks, projection operators trim what you send back, and the positional operators plus arrayFilters let you update exactly the elements you mean.

Search your codebase for queries that put two conditions on the same array's sub-fields without $elemMatch. Each one is a potential wrong-result bug, and fixing it takes a single line.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading