Update Operators ($set, $inc, $unset)
Once documents exist in a MongoDB collection, you rarely want to replace them wholesale every time something changes — you want to change a price, bump a counter, or remove a field that is no longer needed, while leaving the rest of the document untouched. That is exactly what MongoDB’s update operators are for. $set, $inc, and $unset are the three most commonly used operators for modifying documents in place, and together they cover the majority of real-world update logic you will write, from adjusting inventory counts to cleaning up deprecated fields.
Overview: How Update Operators Work
A MongoDB document is a BSON object, not a row in a fixed-schema table, so ‘updating’ a document means sending the server a small instruction describing exactly which parts of the document should change. That instruction is the update document, and its keys are update operators — special string keys starting with $ that tell MongoDB how to modify the matched document rather than what value to store literally.
$set assigns a new value to a field, creating the field if it does not already exist and leaving every other field untouched. $inc increments (or decrements, using a negative number) a numeric field by a given amount atomically, and creates the field starting from the increment value if it is missing. $unset removes a field from the document entirely — the field key disappears, it is not set to null. All three operators can be combined in a single update document, and MongoDB applies every operator in that document to the matched document(s) as one operation.
This matters because it is fundamentally different from what happens when you call updateOne() or updateMany() with a plain object that has no $ operators at all. In that case MongoDB treats the object as a full replacement document and overwrites the entire matched document with it, keeping only _id. Forgetting this distinction is one of the most common MongoDB mistakes, covered later in this lesson.
Single-document writes in MongoDB are atomic by design: even when an update document contains $set, $inc, and $unset together, all of the changes to one document are applied as a single indivisible operation, so another reader can never observe the document half-updated. If the filter touches a field that has an index, the query planner uses that index to locate the matching document(s) instead of scanning the whole collection, and after the write completes MongoDB also updates any indexes built on fields that changed. That index maintenance is not free, so writes that repeatedly modify heavily-indexed fields cost more than writes to unindexed ones.
updateOne() stops after modifying the first document that matches the filter; updateMany() applies the same update document to every matching document. Both return a result summary object rather than the updated document itself. If you need the new document back in the same round trip, use findOneAndUpdate() instead.
Syntax
The general shape of an update call looks like this:
db.collectionName.updateOne(
filter,
{
$set: { fieldA: newValueA, fieldB: newValueB },
$inc: { fieldC: incrementAmount },
$unset: { fieldD: '' }
},
options
);
filter— a query document selecting which document(s) to update, using the same syntax asfind().update— an update document made of one or more operators.options(optional) — a settings object. The most useful option isupsert: true, which inserts a new document built from the filter and update if nothing matches;writeConcerncontrols how many replica set members must acknowledge the write before it is considered successful.
| Operator | Purpose | Example |
|---|---|---|
$set |
Sets a field to a new value, creating it if it does not exist | { $set: { price: 22.99 } } |
$inc |
Increments (or, with a negative number, decrements) a numeric field | { $inc: { stock: -5 } } |
$unset |
Removes a field entirely; the value supplied is ignored, an empty string is conventional | { $unset: { onSale: '' } } |
Examples
These examples build on a small products collection. Start by inserting a few documents:
db.products.insertMany([
{ _id: 1, name: 'Wireless Mouse', price: 25.99, stock: 140, category: 'Electronics', tags: ['input', 'wireless'] },
{ _id: 2, name: 'Mechanical Keyboard', price: 89.99, stock: 60, category: 'Electronics' },
{ _id: 3, name: 'Desk Lamp', price: 19.5, stock: 200, category: 'Home' }
]);
Output:
{
acknowledged: true,
insertedIds: { '0': 1, '1': 2, '2': 3 }
}
Example 1: Changing a value with $set
The mouse just went on sale, so its price needs to change and it needs a new onSale flag — a field that does not exist yet on this document.
db.products.updateOne(
{ _id: 1 },
{ $set: { price: 22.99, onSale: true } }
);
Output:
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
The result tells you one document was matched and one was modified; it does not show the document itself. A follow-up read confirms the change and shows that $set both changed price and created the previously-absent onSale field, without touching stock, category, or tags:
db.products.findOne({ _id: 1 });
{
_id: 1,
name: 'Wireless Mouse',
price: 22.99,
stock: 140,
category: 'Electronics',
tags: [ 'input', 'wireless' ],
onSale: true
}
Example 2: Adjusting a counter with $inc
A customer buys 5 mice, and the product page’s view counter needs to go up by 1. views does not exist on the document yet.
db.products.updateOne(
{ _id: 1 },
{ $inc: { stock: -5, views: 1 } }
);
Output:
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
$inc read the current stock value, subtracted 5, and wrote the result back atomically — no read-modify-write race condition, even under concurrent updates. Since views did not exist, MongoDB created it starting from the increment amount:
db.products.findOne({ _id: 1 }, { name: 1, stock: 1, views: 1, _id: 0 });
{ name: 'Wireless Mouse', stock: 135, views: 1 }
Example 3: Removing a field with $unset
The sale is over, so the temporary onSale field should disappear instead of being set to false.
db.products.updateOne(
{ _id: 1 },
{ $unset: { onSale: '' } }
);
Output:
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
The value given to onSale in the $unset document (here, an empty string) is completely ignored — only the key matters. The field is now gone from the document rather than present with a falsy value, which matters if later code checks 'onSale' in doc or uses $exists:
db.products.findOne({ _id: 1 });
{
_id: 1,
name: 'Wireless Mouse',
price: 22.99,
stock: 135,
category: 'Electronics',
tags: [ 'input', 'wireless' ],
views: 1
}
Example 4: Combining operators across many documents
A more realistic case: flag every low-stock Electronics product and bump a restock-alert counter on each one, in a single pass.
db.products.updateMany(
{ category: 'Electronics', stock: { $lt: 100 } },
{
$set: { lowStock: true },
$inc: { restockAlerts: 1 }
}
);
Output:
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
Only the Mechanical Keyboard matches (Electronics with stock under 100), so matchedCount and modifiedCount are both 1 even though updateMany() was used. If a document already had lowStock: true and the update set it to the same value again, MongoDB still counts it as matched but not modified — modifiedCount reflects documents that actually changed, not just documents that matched the filter.
How It Works Step by Step
When you call updateOne() or updateMany(), MongoDB performs roughly these steps internally:
- The query planner evaluates the filter. If a usable index exists on the filtered fields, it performs an index scan (
IXSCAN) to jump straight to candidate documents; otherwise it performs a full collection scan (COLLSCAN), examining every document. You can see which one happens withexplain():
db.products.find({ stock: { $lt: 100 } }).explain('executionStats');
{
executionStats: {
executionStages: {
stage: 'COLLSCAN',
nReturned: 1,
docsExamined: 3
}
}
}
On a 3-document toy collection a collection scan is irrelevant, but on a collection with millions of documents, an unindexed filter on an update is just as expensive as an unindexed find() — MongoDB still has to examine every document to see if it matches before it can update any of them. Adding a supporting index changes that stage to IXSCAN:
db.products.createIndex({ category: 1, stock: 1 });
category_1_stock_1
- Once a matching document (or set of documents, for
updateMany()) is located, MongoDB applies every operator in the update document to it —$set,$inc, and$unsetare each resolved in turn against the in-memory copy of that one document. - The modified document is written back to the storage engine (WiredTiger) as a single atomic operation. No other operation can observe the document in a partially-updated state, even though multiple operators were applied.
- Any indexes covering fields that changed are updated to point at the new document version. Fields that were not touched by the update leave their indexes untouched.
- MongoDB returns a result summary:
matchedCount(documents the filter matched),modifiedCount(documents whose content actually changed), andupsertedCount/upsertedIdifupsert: truecaused an insert. The updated document itself is not returned unless you usefindOneAndUpdate().
Common Mistakes
Mistake 1: Forgetting the operator and accidentally replacing the whole document. If the second argument to updateOne() has no $ operators at all, MongoDB does not merge it — it replaces the entire document with it, keeping only _id.
// WRONG: no $set operator means this REPLACES the whole document
db.products.updateOne(
{ _id: 3 },
{ price: 17.99 }
);
// The Desk Lamp document becomes just { _id: 3, price: 17.99 }
// name, stock, and category are all gone
Wrap the change in $set so only that field is touched:
db.products.updateOne(
{ _id: 3 },
{ $set: { price: 17.99 } }
);
Mistake 2: Using updateOne() when every matching document needs the change. updateOne() silently stops after the first match — it will not warn you that other documents also matched the filter but were left untouched.
// WRONG: updateOne only touches the FIRST matching document
db.products.updateOne(
{ category: 'Electronics' },
{ $set: { taxable: true } }
);
// Only one Electronics product ends up with taxable: true
Use updateMany() when the intent is to update every document that matches:
db.products.updateMany(
{ category: 'Electronics' },
{ $set: { taxable: true } }
);
Mistake 3: Using $inc on a field that is not numeric. $inc requires the target field to already hold a number (or be absent). Applying it to a string, array, or other BSON type throws an error and the write fails.
// WRONG: $inc requires the target field to already be numeric
db.products.updateOne(
{ _id: 2 },
{ $inc: { name: 1 } }
);
// Throws: MongoServerError: Cannot apply $inc to a value of non-numeric type
Only apply $inc to fields that are genuinely numeric counters or quantities:
db.products.updateOne(
{ _id: 2 },
{ $inc: { stock: 10 } }
);
Best Practices
- Always include at least one update operator (
$set,$inc,$unset, etc.) in the update document unless you deliberately intend to replace the whole document. - Default to
updateOne()only when you are certain the filter matches exactly one document (ideally a unique field like_id); reach forupdateMany()whenever the filter could match more than one. - Check
matchedCountandmodifiedCountin application code — a matched-but-not-modified result (setting a value to what it already was) is normal and not an error, but a matchedCount of 0 usually means your filter is wrong. - Make sure fields used in an update’s filter are indexed on large collections; run
.explain('executionStats')on the equivalentfind()query and confirm it reportsIXSCAN, notCOLLSCAN. - Use
$unsetinstead of$set-to-nullwhen a field should truly disappear (schema cleanup, removing deprecated fields) — anullvalue still satisfies$exists: trueand shows up in projections. - Combine
$setand$incin one call when several fields on the same document need to change together, rather than issuing separate round trips — it is both faster and keeps the change atomic. - Use
upsert: truedeliberately, not by accident — an unintended upsert can silently create a new document when you expected the filter to match an existing one.
Practice Exercises
- In the
productscollection, write an update that setsdiscontinued: trueon the Desk Lamp (_id: 3) and simultaneously removes itscategoryfield. Expect a result withmatchedCount: 1, modifiedCount: 1, and a follow-upfindOne()with nocategorykey. - Write an update that increases
stockby 25 on every document wherestockis currently below 100, using a singleupdateMany()call. Check how many documents were modified. - Using
explain('executionStats'), compare the query plan fordb.products.find({ category: 'Electronics' })before and after creating an index oncategory. Note which stage name changes and why that matters for anupdateMany()using the same filter.
Summary
$setassigns a new value to a field and creates it if missing, without touching any other field.$incatomically increments (or, with a negative number, decrements) a numeric field, creating it from the increment amount if it does not exist yet.$unsetremoves a field entirely; the value passed to it is ignored.- An update document with no
$operators is treated as a full replacement document and wipes out every other field except_id. updateOne()changes at most one matching document;updateMany()changes every matching document — picking the wrong one is a common source of bugs.- Single-document writes are atomic even when multiple operators are combined in one update call.
- An update’s filter benefits from an index exactly like a
find()query does — check withexplain()on large collections.
