Array Update Operators ($push, $pull, $addToSet)

Arrays are first-class citizens in MongoDB documents — a single field can hold an ordered list of scalar values or embedded subdocuments, and you can grow, shrink, or deduplicate that list without rewriting the whole document. The $push, $addToSet, and $pull update operators let you do exactly that: append new elements, add elements only if they aren’t already present, and remove elements matching a condition — all atomically, in a single round trip to the server. This lesson covers all three in depth, including the modifiers that turn $push into a powerful capped, sorted-list tool.

Overview: How Array Updates Work

In a relational database, a one-to-many relationship usually means a separate table and a join. In MongoDB, if the many side is small, bounded, and almost always read together with the one side, it’s often modeled as an array field directly on the document — a user’s list of skill tags, a blog post’s list of comments, an order’s list of line items. Because BSON documents support arrays natively (unlike a flat SQL row), MongoDB gives you dedicated operators to modify just the array, in place, without you having to read the whole document into your application, splice the array in memory, and write the entire document back.

That read-modify-write dance matters more than it looks. If two requests both read a document, each append one tag to an in-memory array, and both write the whole document back, one of those tags is silently lost — the second write overwrites the first. $push, $addToSet, and $pull avoid this entirely because they are applied server-side, against the current on-disk state of the document, as part of a single atomic write operation. MongoDB guarantees that a write to a single document is atomic — even a write that touches a nested array of subdocuments — so there is no race window between two concurrent updates to the same array field.

$push appends one or more values to the end of an array (creating the array if the field doesn’t exist yet). On its own it doesn’t check for duplicates — pushing the same value twice gives you two copies. Combined with modifiers ($each, $sort, $slice, $position) it becomes much more capable: you can push multiple values at once, insert them at a specific index, re-sort the whole array afterward, and cap its length — which is exactly how you’d implement something like keeping only the 10 most recent login timestamps without ever reading the array into your application first.

$addToSet is $push with set semantics: it only appends a value if an element deeply equal to it isn’t already present in the array. It’s the right choice whenever the array represents a set of distinct values (tags, roles, permissions) rather than a sequence where duplicates and order matter (a log, a list of orders). Under the hood, $addToSet does a linear scan of the existing array comparing each element by BSON deep equality — for a subdocument, every field and value must match exactly, so { role: "admin" } and { role: "admin", active: true } are considered different elements.

$pull removes every element from an array that matches a given condition — not just one. The condition can be a literal value, a query with operators ($gt, $in, etc.), or, for arrays of subdocuments, a nested query document matching on one or more fields. This is different from $pop, which removes only the first or last element regardless of its value, and from $pullAll, which removes an exact list of values with no query matching at all.

Syntax

All three operators are used inside the update document of updateOne() or updateMany(), alongside a filter document that selects which document(s) to update:

// $push - append to an array (creates the array if it doesn't exist)
db.collection.updateOne(filter, { $push: { arrayField: value } });

// $push with modifiers: add multiple values, sort, then cap the length
db.collection.updateOne(filter, {
  $push: {
    arrayField: {
      $each: values,
      $sort: sortOrder,
      $slice: limit,
      $position: index
    }
  }
});

// $addToSet - append only if the value isn't already present
db.collection.updateOne(filter, { $addToSet: { arrayField: value } });

// $addToSet with $each - add multiple unique values in one call
db.collection.updateOne(filter, { $addToSet: { arrayField: { $each: values } } });

// $pull - remove every element matching a condition
db.collection.updateOne(filter, { $pull: { arrayField: condition } });
Parameter / Modifier Used with Meaning
value $push, $addToSet A single scalar, document, or array to add as one element.
$each $push, $addToSet Supplies an array of values to add individually, instead of adding the whole array as one element.
$sort $push only 1 or -1 for scalars (or { field: 1 } for subdocuments) — re-sorts the entire array after the push.
$slice $push only A positive N keeps the first N elements after sorting/inserting; a negative N keeps the last N. Used to cap array length.
$position $push only The zero-based index at which to insert the new elements (default is the end of the array).
condition $pull A value, an operator expression ({ $gt: 10 }), or a query document matching subdocument fields — every array element matching it is removed.

Examples

Example 1: Appending with $push

Suppose db.users has a document { username: "asha_dev", skills: ["JavaScript", "MongoDB"] }. To add a new skill:

db.users.updateOne(
  { username: "asha_dev" },
  { $push: { skills: "Kubernetes" } }
);

Output:

{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}

skills is now ["JavaScript", "MongoDB", "Kubernetes"]. If you ran this exact command again, $push would happily add a second "Kubernetes" — it has no notion of uniqueness.

Example 2: Deduplicating with $addToSet and $each

To add a skill only if it isn’t already present, and to add several at once:

db.users.updateOne(
  { username: "asha_dev" },
  { $addToSet: { skills: "Kubernetes" } }
);

db.users.updateOne(
  { username: "asha_dev" },
  { $addToSet: { skills: { $each: ["Terraform", "Go"] } } }
);

Output:

// first call - "Kubernetes" already exists, nothing changes
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 0, upsertedCount: 0 }

// second call - both new skills are appended
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }

The first update matches the document (matchedCount: 1) but reports modifiedCount: 0 — MongoDB found "Kubernetes" already in the array and made no change. The second update, using $each, evaluates "Terraform" and "Go" independently and appends whichever ones are missing.

Example 3: Capping and sorting with $push, $each, $sort, $slice

Suppose db.students has a document with _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") and quizScores: [85, 90, 65, 88]. To record two new scores while keeping only the top 3 overall:

db.students.updateOne(
  { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") },
  {
    $push: {
      quizScores: {
        $each: [92, 78],
        $sort: -1,
        $slice: 3
      }
    }
  }
);

db.students.findOne(
  { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") },
  { quizScores: 1, _id: 0 }
);

Output:

{ quizScores: [ 92, 90, 88 ] }

MongoDB merges [92, 78] into the existing array, sorts the combined 6-element array descending ([92, 90, 88, 85, 78, 65]), then $slice: 3 keeps only the first three. This single atomic update replaces what would otherwise be a fetch, an in-memory sort, a truncate, and a full-document write.

Example 4: Removing matching elements with $pull

Suppose db.carts has a document with _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2") and an items array of subdocuments, some of which are now out of stock:

db.carts.updateOne(
  { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2") },
  { $pull: { items: { inStock: false } } }
);

db.carts.findOne(
  { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2") },
  { items: 1, _id: 0 }
);

Output:

{ items: [ { product: "Widget", qty: 2, inStock: true } ] }

$pull scanned every subdocument in items, removed every one where inStock was false (there were two), and left the rest — in one pass, regardless of how many matched.

How It Works Step by Step

When you run one of these updates, the mongod server:

  1. Uses the filter document to locate the target document(s) — this is a normal query, so an index on the filter’s fields (e.g. username or _id) determines whether it’s an IXSCAN or a full COLLSCAN, exactly as with find().
  2. Takes out a lock on that document (WiredTiger provides document-level concurrency, not collection-level) so no other write can interleave with this one.
  3. Reads the current BSON value of the target array field.
  4. For $push: appends the value(s), then applies $sort (re-sorting the whole array) and $slice (trimming to length) if present, in that order.
  5. For $addToSet: for each candidate value, performs a deep BSON-equality comparison against every existing element; only values with no match are appended.
  6. For $pull: evaluates the condition against every existing element and removes each one that matches, compacting the array.
  7. Writes the modified document back and, depending on the write concern, waits for acknowledgment from the primary (and optionally secondaries) before returning the result document you see in the shell.

Because all of this happens as one atomic operation on one document, there’s no window where another client could see a half-updated array, and no risk of the classic read-modify-write lost update.

Common Mistakes

Mistake 1: Using $push when you meant $addToSet

Wrong — pushing the same tag repeatedly creates duplicates:

db.users.updateOne({ username: "asha_dev" }, { $push: { skills: "Go" } });
db.users.updateOne({ username: "asha_dev" }, { $push: { skills: "Go" } });
// skills now contains "Go" twice

Correct — use $addToSet whenever the array should hold distinct values:

db.users.updateOne({ username: "asha_dev" }, { $addToSet: { skills: "Go" } });

Mistake 2: Forgetting $each with $addToSet when adding multiple values

Wrong — without $each, the array itself is treated as a single element to add:

db.users.updateOne(
  { username: "asha_dev" },
  { $addToSet: { skills: ["Rust", "C++"] } }
);
// skills becomes [..., ["Rust", "C++"]] - one new element that is itself an array

Correct — wrap the values in $each so each one is evaluated and inserted individually:

db.users.updateOne(
  { username: "asha_dev" },
  { $addToSet: { skills: { $each: ["Rust", "C++"] } } }
);

Mistake 3: Using updateOne when you meant updateMany

Wrong — $pull is correct, but updateOne() only cleans up the single first-matching cart, silently leaving every other abandoned cart untouched:

db.carts.updateOne(
  { status: "abandoned" },
  { $pull: { items: { inStock: false } } }
);
// only one abandoned cart gets cleaned, even if hundreds match the filter

Correct — use updateMany() whenever the filter is meant to match more than one document:

db.carts.updateMany(
  { status: "abandoned" },
  { $pull: { items: { inStock: false } } }
);

Best Practices

  • Use $addToSet for tags, roles, and category lists where duplicates are meaningless; use $push for logs, histories, and ordered sequences where duplicates and order are meaningful.
  • Cap unbounded arrays with $push plus $slice (e.g. keep only the last 100 events) — arrays that grow forever risk hitting the 16MB document size limit and make every read of the document heavier.
  • Filter $pull conditions as precisely as you would a find() query — test them with find() first if you’re unsure exactly which subdocuments will match.
  • Reach for updateMany(), not updateOne(), whenever the filter is expected to match more than one document — updateOne() only ever touches the first match it finds.
  • When the array holds subdocuments, make sure the fields you compare in $addToSet or $pull are consistently typed (e.g. don’t mix a string "5" and a number 5 for the same logical field) — BSON deep equality treats different types as different values.
  • For very large or unbounded child collections (e.g. millions of reviews for a product), reference a separate collection instead of embedding an array at all — array update operators aren’t a substitute for correct schema design.

Practice Exercises

  1. Given a db.articles collection where each document has a tags array, write an update that adds "featured" to the tags of the article titled "Intro to Indexes" only if it isn’t already tagged that way.
  2. Given db.devices with a pingHistory array of numeric response times, write an update that pushes a new reading and keeps only the most recent 20 readings (hint: think about what $slice with a negative number does).
  3. Given db.orders where each order has an items array of { sku, qty, cancelled } subdocuments, write an update that removes every cancelled item from every order with status: "processing". Expected result shape: each matching order’s items array no longer contains any element with cancelled: true.

Summary

  • $push appends one or more values to an array; it allows duplicates by default.
  • $each, $sort, $slice, and $position are modifiers for $push that enable bulk inserts, re-sorting, capping array length, and inserting at a specific index.
  • $addToSet behaves like $push but only adds a value if a deeply-equal element isn’t already in the array — use $each with it to add multiple values at once.
  • $pull removes every array element matching a condition, in one atomic operation, regardless of how many elements match.
  • All three operators run atomically against the current state of a single document on the server, avoiding the lost-update race of a manual read-modify-write.
  • Always double-check whether your filter should match one document (updateOne) or many (updateMany) — array operators apply to whichever documents the filter matches.