MongoDB Mastery
MongoDB Mastery lessons / Module 7: Updating & Deleting

Update operators, upserts and replacements

Level 1: Beginner22 min

Modify documents atomically with update operators, create-or-update with upserts, and read the result counters correctly.

This lesson is interactive. Run every example against an in-browser MongoDB, see explain() plans drawn as trees, and take the quiz.
Open the interactive lesson
You will learn to
  • 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

OperatorEffect
$set / $unsetset or remove fields (dot notation creates nested fields)
$inc / $muladd to / multiply a number (creates the field with $inc)
$min / $maxupdate only if the new value is lower / higher
$renamerename a field
$currentDateset to the server's current date
$setOnInsertset 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.

$set, $inc, $unset
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 })
$min, $max, $mul, $currentDate
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 })
Upsert with $setOnInsert
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()
updateMany and matched vs modified
db.orders.updateMany({ status: "pending", createdAt: { $lt: ISODate("2025-01-01") } }, { $set: { status: "expired" } })
db.orders.updateMany({ status: "expired" }, { $set: { status: "expired" } })
The second update matches documents but modifies none, because the value is already set.
findOneAndUpdate
db.products.findOneAndUpdate(
  { _id: "P002", stock: { $gte: 1 } },
  { $inc: { stock: -1 } },
  { returnDocument: "after", projection: { name: 1, stock: 1 } }
)
Pipeline update
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

Avoid
const p = db.products.findOne({ _id: "P001" })
p.stock -= 1
db.products.replaceOne({ _id: "P001" }, p)
Prefer
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.

Avoid
db.products.updateOne({ _id: "P001" }, { price: 199 })
Prefer
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 $inc or findOneAndUpdate.
  • Upserting on a filter with no unique index, which can create duplicates under concurrency.
  • Using $set and $unset on 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
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.