Update operators, upserts and replacements
Modify documents atomically with update operators, create-or-update with upserts, and read the result counters correctly.
- Use $set, $unset, $inc, $mul, $min, $max, $rename, $currentDate
- Distinguish updateOne, updateMany and replaceOne
- Use upsert with $setOnInsert
- Use findOneAndUpdate to update and read atomically
conceptThe concept
| Operator | Effect |
|---|---|
$set / $unset | set or remove fields (dot notation creates nested fields) |
$inc / $mul | add to / multiply a number (creates the field with $inc) |
$min / $max | update only if the new value is lower / higher |
$rename | rename a field |
$currentDate | set to the server's current date |
$setOnInsert | set only when an upsert inserts |
updateOne changes the first match, updateMany all matches, replaceOne swaps the whole document (keeping _id). The result reports matchedCount, modifiedCount (0 if values were already equal) and upsertedId.
Upsert: { upsert: true } inserts a new document built from the equality fields of the filter plus the update if nothing matches. findOneAndUpdate returns the document before or after the change in the same atomic operation.
Since 4.2 an update can be an aggregation pipeline (an array), which allows computing fields from other fields: [{ $set: { total: { $sum: "$items.price" } } }].
whyWhy it matters
Update operators modify a document atomically on the server, so concurrent requests incrementing a counter never lose updates. Read-modify-write in application code (read, change in memory, replace) is a classic race condition.
Use caseReal-world scenario
A page-view counter: updateOne({ _id: pageId }, { $inc: { views: 1 }, $setOnInsert: { createdAt: new Date() } }, { upsert: true }). Thousands of concurrent requests produce the exact count, and the first one creates the document.
Try itExamples
Run and edit these in the interactive lesson.
db.customers.updateOne({ _id: 10 }, { $set: { tier: "gold", "address.zip": "500081" }, $inc: { points: 50 } })
db.customers.updateOne({ _id: 10 }, { $unset: { marketingOptIn: "" } })
db.customers.findOne({ _id: 10 }, { tier: 1, points: 1, address: 1, marketingOptIn: 1 })db.products.updateOne({ _id: "P001" }, { $max: { rating: 4.9 }, $mul: { price: 1.1 }, $currentDate: { updatedAt: true } })
db.products.findOne({ _id: "P001" }, { price: 1, rating: 1, updatedAt: 1 })db.lab_counters.updateOne(
{ _id: "page:/pricing" },
{ $inc: { views: 1 }, $setOnInsert: { firstSeen: new Date() } },
{ upsert: true }
)
db.lab_counters.updateOne({ _id: "page:/pricing" }, { $inc: { views: 1 }, $setOnInsert: { firstSeen: new Date() } }, { upsert: true })
db.lab_counters.find()db.orders.updateMany({ status: "pending", createdAt: { $lt: ISODate("2025-01-01") } }, { $set: { status: "expired" } })
db.orders.updateMany({ status: "expired" }, { $set: { status: "expired" } })db.products.findOneAndUpdate(
{ _id: "P002", stock: { $gte: 1 } },
{ $inc: { stock: -1 } },
{ returnDocument: "after", projection: { name: 1, stock: 1 } }
)db.orders.updateOne(
{ orderNo: "ORD-100010" },
[{ $set: { itemCount: { $sum: "$items.qty" }, recalculatedAt: "$$NOW" } }]
)
db.orders.findOne({ orderNo: "ORD-100010" }, { itemCount: 1, recalculatedAt: 1 })internalsUnder the hood
Each single-document update is atomic: WiredTiger applies it in one storage transaction along with index updates and the oplog entry. The oplog records the result (for example $set: { views: 42 } in a diff format), not $inc, so replaying it is idempotent. Upserts on the same key from concurrent clients can race; a unique index on the filter field turns the loser into a duplicate-key error, which drivers and the server retry for you in many cases since 4.2.
Avoid / preferDo this, not that
const p = db.products.findOne({ _id: "P001" })
p.stock -= 1
db.products.replaceOne({ _id: "P001" }, p)db.products.updateOne(
{ _id: "P001", stock: { $gt: 0 } },
{ $inc: { stock: -1 } })Read-modify-write loses concurrent updates. $inc with a condition is applied atomically by the server and refuses to go below zero.
db.products.updateOne({ _id: "P001" }, { price: 199 })db.products.updateOne({ _id: "P001" }, { $set: { price: 199 } })updateOne requires update operators; a plain document is rejected. If you really mean to replace the whole document, say so with replaceOne.
PitfallsCommon mistakes
- Passing a plain document to
updateOne({ status: "x" }) instead of{ $set: { status: "x" } }; modern drivers reject it, old ones replaced the whole document. - Read-modify-write races instead of
$incorfindOneAndUpdate. - Upserting on a filter with no unique index, which can create duplicates under concurrency.
- Using
$setand$unseton the same path in one update (a conflict error).
LimitsWhen not to use it
An upsert whose filter is not backed by a unique index can race: two concurrent upserts may both insert. Large backfills should not be one updateMany over millions of documents during peak hours; batch them by _id range, or build the new shape with $merge.
SecuritySecurity notes
Never pass client input as an update document: { $set: req.body } lets the client set role, balance or anything else. Build the $set from an allow-list of fields, and reject keys that start with $ or contain dots.
ChallengeTry it yourself
Give every gold customer who has no loyalty.points field 100 points, record when that happened in loyalty.updatedAt, and report how many documents changed.
Show one solution
db.customers.updateMany(
{ tier: "gold", "loyalty.points": { $exists: false } },
{ $set: { "loyalty.points": 100 }, $currentDate: { "loyalty.updatedAt": true } }
)InterviewQuestions you should be able to answer
How do you implement an atomic counter or sequence?
findOneAndUpdate({ _id: "orders" }, { $inc: { seq: 1 } }, { upsert: true, returnDocument: "after" }). The increment is atomic per document. Note it serializes all writers on one document, so for high throughput prefer ObjectIds or pre-allocated ranges.
What is the difference between matchedCount and modifiedCount?
matchedCount is how many documents the filter selected; modifiedCount is how many actually changed. If the update sets values that are already present, the document is matched but not modified, and no oplog entry is written for it.
QuizCheck your understanding
4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.