
MongoDB Update Operators: $set, $inc, $push, $pull, and More
A common first attempt at updating data in MongoDB looks like this: fetch the document, change a field in application code, and write the whole thing back. It works on your laptop. In production, two requests do it at the same time, and one of them silently overwrites the other's changes. A view counter goes up by one when it should have gone up by two, or an item added to a cart disappears.
Update operators fix this. Instead of sending a new version of the document, you send a description of the change: "set this field", "add 5 to that one", "append this value to that array". The server applies the change atomically to the single document, so concurrent updates to different fields (or even the same counter) don't clobber each other. You also send far less data over the wire.
This guide covers the field operators ($set, $unset, $inc, $mul, $min, $max, $rename, $currentDate, $setOnInsert), the array operators ($push, $addToSet, $pull, $pullAll, $pop) with their modifiers, positional updates, pipeline-style updates, and the mistakes that trip people up.
The Shape of an Update
Every update call takes a filter and an update document. The update document's top-level keys are operators, and each operator maps field paths to values:
db.products.updateOne(
{ sku: "LAMP-042" }, // filter: which document
{
$set: { price: 49.99, "stock.warehouse": "east" },
$inc: { "stats.edits": 1 },
},
);
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
Two numbers in that result are worth understanding. matchedCount is how many documents the filter found. modifiedCount is how many actually changed. If you $set a field to the value it already has, you'll see matchedCount: 1, modifiedCount: 0. That isn't an error; MongoDB just skipped a no-op write.
You can combine several operators in one update, as above, but you can't touch the same field path with two operators in the same update. { $set: { qty: 5 }, $inc: { qty: 1 } } fails with a conflict error.
The three main methods are updateOne (first matching document), updateMany (all matches), and findOneAndUpdate (updates one document and returns it, before or after the change). If you haven't used them yet, the CRUD operations guide walks through each one.
Replacement vs. Update: The Classic Trap
If the second argument has no operators, it's treated as a replacement document, not an update. replaceOne is the explicit way to do that:
db.users.replaceOne({ _id: 1 }, { name: "Ada" });
// every other field on the document is now gone
Modern drivers and mongosh refuse to let updateOne or updateMany accept a plain document without operators, and throw an error like "Update document requires atomic operators". That protection exists because accidental replacements have wiped out a lot of production data over the years. If you really want to replace, call replaceOne so the intent is obvious to the next reader.
Field Update Operators
$set and $unset
$set writes a value to a field, creating the field (and any missing parent objects) if it doesn't exist:
db.users.updateOne(
{ email: "ada@example.com" },
{ $set: { "profile.timezone": "Europe/London", verified: true } },
);
If profile didn't exist, MongoDB creates it as { timezone: "Europe/London" }. Note the dot notation: "profile.timezone" changes one nested field. Writing { $set: { profile: { timezone: "..." } } } would replace the whole profile subdocument, dropping any other fields inside it. This distinction causes more bugs than any other operator detail, and it gets a full treatment in the post on nested documents and dot notation.
$unset removes a field entirely. The value you pass is ignored, so by convention it's an empty string or 1:
db.users.updateMany({}, { $unset: { legacyToken: "" } });
Removing a field is different from setting it to null. A query for { legacyToken: null } matches both missing fields and explicit nulls, but { legacyToken: { $exists: false } } only matches documents where the field is truly gone.
$inc and $mul
$inc adds a number (negative values subtract). It's the right way to maintain counters because the read-modify-write happens on the server, atomically:
db.posts.updateOne(
{ slug: "hello-world" },
{ $inc: { views: 1, "reactions.like": 1 } },
);
If the field doesn't exist, $inc creates it with the increment value. $mul multiplies, and creates a missing field with the value 0 (of the same numeric type as the multiplier):
// apply a 10% price increase to a category
db.products.updateMany(
{ category: "lighting" },
{ $mul: { price: NumberDecimal("1.10") } },
);
Both operators fail if the existing field isn't numeric. Also watch the numeric types: $inc on an Int32 field with a large value can overflow into an error, and mixing doubles with Decimal128 gives you Decimal128. For money, store and update Decimal128 values consistently.
$min and $max
These only update the field if the new value is lower ($min) or higher ($max) than the current one. They're perfect for high scores, "first seen" and "last seen" timestamps, and price floors:
db.players.updateOne(
{ _id: "player-17" },
{
$max: { bestScore: 9800 },
$min: { firstSeen: new Date("2026-09-01T10:00:00Z") },
},
);
If bestScore is already 10,200, nothing changes. Without $max you'd need to read the score first, compare in your code, and hope nobody else wrote in between.
$rename
$rename moves a field to a new name, which is handy for schema clean-ups:
db.customers.updateMany(
{ phone_number: { $exists: true } },
{ $rename: { phone_number: "phone" } },
);
It works on nested paths too ("address.zip": "address.postalCode"), but it doesn't work on fields inside arrays. If the target name already exists, it's overwritten.
$currentDate
$currentDate sets a field to the server's current time, which avoids clock skew between application servers:
db.orders.updateOne(
{ _id: orderId },
{
$set: { status: "shipped" },
$currentDate: { updatedAt: true, shippedAt: { $type: "date" } },
},
);
true and { $type: "date" } both store a regular Date. { $type: "timestamp" } stores a BSON internal timestamp, which you almost never want in application data.
$setOnInsert
$setOnInsert only applies when an upsert creates a new document. On a normal update it's ignored:
db.visitors.updateOne(
{ visitorId: "v-8812" },
{
$inc: { visits: 1 },
$currentDate: { lastVisit: true },
$setOnInsert: { firstVisit: new Date(), plan: "free" },
},
{ upsert: true },
);
That combination (increment always, initialize once) is one of the most useful patterns in MongoDB. The dedicated guide to upserts digs into it further.
Array Update Operators
$push, with $each, $sort, and $slice
$push appends a value to an array, creating the array if the field is missing:
db.tickets.updateOne(
{ _id: 501 },
{
$push: { comments: { by: "sam", text: "Looking into it", at: new Date() } },
},
);
To push several values, wrap them in $each. Without it, pushing an array inserts that array as a single nested element, which is rarely what you meant:
// wrong: tags becomes [..., ["new", "sale"]]
{
$push: {
tags: ["new", "sale"];
}
}
// right: tags becomes [..., "new", "sale"]
{
$push: {
tags: {
$each: ["new", "sale"];
}
}
}
$each unlocks three modifiers. $position inserts at an index instead of the end, $sort sorts the array after the push, and $slice trims it. Together they give you a capped, sorted list in a single atomic update:
// keep only the 5 most recent notifications
db.users.updateOne(
{ _id: userId },
{
$push: {
notifications: {
$each: [{ msg: "New follower", at: new Date() }],
$sort: { at: -1 },
$slice: 5,
},
},
},
);
A positive $slice keeps the first N elements, a negative one keeps the last N. This pattern is great for "recent activity" lists that would otherwise grow without bound.
$addToSet
$addToSet adds a value only if it isn't already present, so the array behaves like a set:
db.articles.updateOne(
{ _id: 12 },
{ $addToSet: { tags: { $each: ["mongodb", "database", "mongodb"] } } },
);
Equality for embedded documents is exact: same fields, same values, same field order. { a: 1, b: 2 } and { b: 2, a: 1 } are considered different, so $addToSet is most reliable with scalar values.
$pull and $pullAll
$pull removes every element that matches a value or a condition:
// remove a specific tag
db.articles.updateOne({ _id: 12 }, { $pull: { tags: "draft" } });
// remove all scores below 50
db.students.updateMany({}, { $pull: { scores: { $lt: 50 } } });
// remove embedded documents that match a condition
db.carts.updateOne({ userId: 7 }, { $pull: { items: { sku: "MUG-01" } } });
For arrays of subdocuments, the condition is applied to each element as if it were its own document, so { sku: "MUG-01" } matches any item with that SKU regardless of other fields.
$pullAll removes every occurrence of each listed value, with exact matching only:
db.articles.updateOne({ _id: 12 }, { $pullAll: { tags: ["old", "wip"] } });
$pop
$pop removes the first (-1) or last (1) element. It's a simple way to treat an array as a stack or queue:
db.queues.updateOne({ name: "emails" }, { $pop: { pending: -1 } });
Positional Operators
Sometimes you need to update a specific element inside an array rather than add or remove one. MongoDB has three positional operators for this:
| Operator | Updates |
|---|---|
$ | The first element matched by the query filter |
$[] | Every element in the array |
$[identifier] | Every element matching an arrayFilters condition |
// $ : change the quantity of the item the filter matched
db.carts.updateOne(
{ userId: 7, "items.sku": "LAMP-042" },
{ $inc: { "items.$.qty": 1 } },
);
// $[] : mark every item as reviewed
db.carts.updateOne({ userId: 7 }, { $set: { "items.$[].reviewed": true } });
// $[low] : restock only items with less than 5 units
db.inventory.updateOne(
{ warehouse: "east" },
{ $inc: { "bins.$[low].qty": 20 } },
{ arrayFilters: [{ "low.qty": { $lt: 5 } }] },
);
These get much deeper coverage, including nested arrays and $elemMatch, in the guide to working with arrays in MongoDB.
Updates with an Aggregation Pipeline
Classic operators can't reference other fields in the same document. You can't write "set total to price * qty" with $set alone. For that, pass an array of aggregation stages as the update:
db.orderLines.updateMany({ total: { $exists: false } }, [
{ $set: { total: { $multiply: ["$price", "$qty"] } } },
{
$set: {
status: { $cond: [{ $gte: ["$total", 100] }, "priority", "normal"] },
},
},
]);
Inside a pipeline update, $set is the aggregation stage (an alias of $addFields), and "$price" refers to the document's current value. Allowed stages include $set, $addFields, $unset, $project, $replaceRoot, and $replaceWith.
Pipeline updates are powerful for migrations and derived fields. The trade-off is readability: a pipeline that rewrites five fields with nested $cond expressions is harder to review than a few operator updates. Use them when you genuinely need to reference existing values. If you need a literal string that starts with $, wrap it in $literal so it isn't read as a field path.
Using Update Operators from a Driver
Everything above translates directly to the drivers. Here's the Node.js driver version of a cart update:
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI);
const carts = client.db("shop").collection("carts");
const result = await carts.updateOne(
{ userId: 7, "items.sku": { $ne: "LAMP-042" } },
{
$push: { items: { sku: "LAMP-042", qty: 1, price: 49.99 } },
$currentDate: { updatedAt: true },
},
);
if (result.matchedCount === 0) {
// either the cart doesn't exist or the item is already in it
await carts.updateOne(
{ userId: 7, "items.sku": "LAMP-042" },
{ $inc: { "items.$.qty": 1 } },
);
}
And the same idea in PyMongo:
from datetime import datetime, timezone
from pymongo import MongoClient
client = MongoClient("mongodb://localhost:27017")
posts = client.blog.posts
result = posts.update_one(
{"slug": "hello-world"},
{
"$inc": {"views": 1},
"$set": {"lastViewedAt": datetime.now(timezone.utc)},
},
)
print(result.matched_count, result.modified_count) # 1 1
Common Pitfalls
Replacing a subdocument when you meant to change one field. { $set: { address: { city: "Leeds" } } } wipes out street and postcode. Use { $set: { "address.city": "Leeds" } }.
Pushing an array instead of its elements. $push with a raw array creates a nested array. Use $each whenever you add more than one value.
Reading then writing in application code. If you fetch a document, modify a counter, and save it, you have a race condition. Let $inc, $min, $max, and $addToSet do the work on the server.
Ignoring matchedCount. An update that matched nothing doesn't throw. If your code assumes a document exists, check matchedCount and handle zero explicitly, or use an upsert if creating it is acceptable.
Unbounded array growth. $push without $slice on an activity log will eventually push a document toward the 16 MB BSON limit, and long before that it hurts performance. Cap arrays or move the data to its own collection.
Forgetting that updateMany has no undo. Test the filter first with countDocuments() and find().limit(5) before running a bulk update in production.
Conclusion
Update operators are how you tell MongoDB what changed instead of what the whole document should look like. $set and $unset handle ordinary fields, $inc, $mul, $min, and $max handle numeric logic safely on the server, $push, $addToSet, $pull, and $pop manage arrays, and positional operators and pipeline updates cover the harder cases. Used well, they make your writes atomic, compact, and free of lost updates.
Pick one place in your codebase that loads a document, modifies it, and saves it back. Rewrite it as a single updateOne with the right operators, and you'll have removed a race condition and a round trip in the same change.


