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

Arrays in MongoDB: a MongoDB & NoSQL guide for developers

Store and query lists well.

Expona Admin7 min read

Introduction

Arrays are one of MongoDB's most powerful features—they let you store and query lists of values directly within a document, eliminating the need for separate collection joins in many cases. Understanding how to work effectively with arrays in MongoDB is essential for building scalable NoSQL applications, whether you're tracking order items, user preferences, or sensor readings. In this guide, Arrays in MongoDB: a MongoDB & NoSQL guide for developers will walk you through the patterns, pitfalls, and performance techniques that senior teams use in production.

Why Arrays Matter in NoSQL Design

Traditional relational databases force you to normalize lists into separate tables. MongoDB's document model lets you embed arrays directly, which can dramatically simplify queries and reduce round-trips to the database. However, flexibility comes with responsibility: unbounded growth, missing indexes, and inefficient queries can quickly become bottlenecks.

The key question is always: should this be an array inside the document, or a separate collection? Generally, if you query the array independently or if it grows without bound, a separate collection is better. If you almost always fetch the parent document and its array together, and the array is logically small and stable, embedding as an array is the right choice.

Multikey Indexes for Array Fields

When you index a field that contains an array, MongoDB automatically creates a multikey index. Each array element gets its own index entry, so queries that search within arrays are still fast.

Consider an e-commerce order document:

javascript
db.orders.insertOne({
  _id: ObjectId("..."),
  customerId: "cust_123",
  items: [
    { sku: "WIDGET-A", qty: 2 },
    { sku: "WIDGET-B", qty: 1 }
  ],
  createdAt: new Date()
});

If you query orders by item SKU frequently, create an index on the array field:

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

Now this query uses the index:

javascript
db.orders.find({ "items.sku": "WIDGET-A" })

MongoDB treats items.sku as a multikey index and creates one index entry per array element. The explain output will show stage: "COLLSCAN" if the index isn't being used, or stage: "IXSCAN" if it is.

One caveat: you cannot create a compound index where two or more fields are arrays in the same document. This is a natural limitation—the index would explode combinatorially. If you find yourself needing that, it's often a signal to denormalize or restructure your schema.

$all and $size Queries

MongoDB provides dedicated array operators for precise queries. $all matches documents where an array contains all of the specified values (in any order).

javascript
// Find orders containing both WIDGET-A and WIDGET-B
db.orders.find({ "items.sku": { $all: ["WIDGET-A", "WIDGET-B"] } })

This is useful for filtering by multiple tags, permissions, or feature flags. Note that $all doesn't guarantee the values are adjacent or in a specific position.

$size matches arrays of a specific length:

javascript
// Find orders with exactly 3 items
db.orders.find({ items: { $size: 3 } })

Important: $size does not use an index efficiently in most MongoDB versions. If you frequently query by array length, consider denormalizing a count field alongside the array:

javascript
db.orders.insertOne({
  _id: ObjectId("..."),
  customerId: "cust_123",
  itemCount: 2,
  items: [
    { sku: "WIDGET-A", qty: 2 },
    { sku: "WIDGET-B", qty: 1 }
  ]
});

db.orders.createIndex({ itemCount: 1 });

// Now queries by count are indexed
db.orders.find({ itemCount: 2 })

Denormalizing a count is a trade-off: you must keep it in sync on every update, but reads become efficient.

Positional Operators for Updates

When updating elements within an array, you need to identify which element to modify. MongoDB's positional operator $ refers to the matched array index.

javascript
// Update the quantity of the first matching WIDGET-A in an order
db.orders.updateOne(
  { _id: ObjectId("..."), "items.sku": "WIDGET-A" },
  { $set: { "items.$.qty": 5 } }
);

Here, $ is replaced by MongoDB with the index of the array element that matched the query. Without the positional operator, you'd have to fetch the entire array, modify it in application code, and replace the whole array—inefficient and error-prone.

For more complex scenarios, use $[identifier] with arrayFilters:

javascript
// Update all items with qty > 0 to be marked as processed
db.orders.updateOne(
  { _id: ObjectId("...") },
  { $set: { "items.$[elem].processed": true } },
  { arrayFilters: [{ "elem.qty": { $gt: 0 } }] }
);

This is powerful for bulk updates within an array without knowing exact positions ahead of time. However, arrayFilters can only filter on existing fields; if you need conditional logic beyond field comparisons, you may need to use aggregation pipelines in update operations (available in MongoDB 4.2+):

javascript
db.orders.updateOne(
  { _id: ObjectId("...") },
  [
    {
      $set: {
        items: {
          $map: {
            input: "$items",
            as: "item",
            in: {
              $mergeObjects: [
                "$$item",
                { processed: { $gt: ["$$item.qty", 0] } }
              ]
            }
          }
        }
      }
    }
  ]
);

Aggregation pipeline updates are more flexible but slightly slower; use them when arrayFilters can't express your logic.

Keep Arrays Bounded

One of the biggest gotchas with MongoDB arrays is unbounded growth. If you append to an array without limit—say, logging events or reactions to a post—the document will bloat indefinitely, consuming more memory and bandwidth on every read.

Use $push with $each and $slice to maintain a fixed-size array:

javascript
// Keep only the last 100 events in a user's activity log
db.users.updateOne(
  { _id: "user_456" },
  {
    $push: {
      activityLog: {
        $each: [{ action: "login", timestamp: new Date() }],
        $slice: -100  // Keep last 100 elements
      }
    }
  }
);

The $slice: -100 automatically removes older entries when the array exceeds 100 elements, keeping the document size predictable.

Another pattern is the separate collection for high-volume lists. If you're tracking thousands of transactions per user or millions of events per device, store them in a separate collection with a reference back to the parent:

javascript
// Parent collection
db.users.insertOne({
  _id: "user_456",
  name: "Alice",
  // No events array
});

// Separate collection with foreign key
db.events.insertMany([
  { userId: "user_456", action: "login", timestamp: new Date() },
  { userId: "user_456", action: "purchase", timestamp: new Date() }
]);

// Query with indexed lookup
db.events.find({ userId: "user_456" }).sort({ timestamp: -1 }).limit(10);

This scales better because each event is a lightweight document, and you can paginate through them without loading the entire history.

Common Mistakes

  • Indexing without considering multikey cost: Multikey indexes have higher insert overhead because each array element needs an index entry. If writes dominate and array queries are rare, reconsider whether the index is worth it.
  • Using $size in hot queries: As mentioned, $size doesn't use indexes well. Always explain your queries and verify they're hitting indexes.
  • Forgetting array atomicity: If one thread pushes to an array while another updates an element, the operations are serialized at the document level in MongoDB, but your application must be aware of the order. Don't assume concurrent array modifications preserve a specific interleaving.
  • Embedding arrays that should be separate collections: If an array grows unbounded or is queried independently from its parent, make it a separate collection. This is not a relational taboo in NoSQL—it's practical design.
  • Not denormalizing counts when needed: If you frequently query by array size, add a count field and keep it in sync. It's a small denormalization win that pays dividends.

Key Takeaways

  • Create multikey indexes on array fields you query frequently to ensure fast lookups of individual elements.
  • Use $all for multi-value containment checks and denormalize counts instead of relying on $size for performance-critical queries.
  • Leverage the positional operator $ for single-element updates and arrayFilters (or pipeline updates) for conditional bulk modifications within arrays.
  • Always bound arrays using $slice or move high-volume data to separate collections to prevent document bloat and keep read performance predictable.
  • Think of NoSQL arrays as an embedding convenience, not a mandate—when scale or independence demands it, split into a separate collection without guilt.
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