MongoDB Cheatsheet
Update Operators
Use this MongoDB reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Update methods
Every update is (filter, update, options). The filter is an ordinary query, so
everything from the query operators page applies.
db.orders.updateOne({ _id: ObjectId("6612...") }, { $set: { status: "shipped" } }) db.orders.updateMany({ status: "pending" }, { $set: { status: "cancelled" } }) db.orders.replaceOne({ _id: 1 }, { sku: "A-1", qty: 4 }) db.orders.findOneAndUpdate( { status: "pending" }, { $set: { status: "processing" }, $currentDate: { claimed_at: true } }, { sort: { placed_at: 1 }, returnDocument: "after" } )
| Method | Returns |
|---|---|
updateOne | Counts. Touches at most one document, the first the filter finds |
updateMany | Counts. Not atomic across documents, other writers interleave |
replaceOne | Counts. Swaps the whole document, _id excepted |
findOneAndUpdate | The document itself, before or after the change |
findOneAndReplace | Same, with a replacement |
bulkWrite | Mixed operations in one round trip |
// { acknowledged: true, insertedId: null, // matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }
matchedCount and modifiedCount differ when the new value already equals the old
one, because the server skips a no-op write. A modifiedCount of 0 with
matchedCount of 1 means "found it, nothing changed", not "failed".
findOneAndUpdate defaults to returnDocument: "before", so pass "after" when you
want the result of the change. The old shell spelled that returnNewDocument: true.
Field operators
| Operator | Does |
|---|---|
$set | Set a field, creating it and any missing parents |
$unset | Remove a field. The value you pass is ignored, convention is "" |
$inc | Add a number, negative to subtract. Creates the field if absent |
$mul | Multiply. Creates the field as 0 if absent |
$min | Set only if the given value is less than the current one |
$max | Set only if greater |
$rename | Rename a field. Cannot move a field into or out of an array |
$currentDate | Server clock. true for a Date, { $type: "timestamp" } for a Timestamp |
$setOnInsert | Apply only when an upsert actually inserts |
$bit | and / or / xor on an integer field |
db.products.updateOne({ sku: "A-1" }, { $set: { "meta.reviewed": true, price: NumberDecimal("64.99") }, $inc: { views: 1, stock: -2 }, $min: { lowest_price: NumberDecimal("64.99") }, $unset: { legacy_code: "" }, $rename: { descr: "description" }, $currentDate: { updated_at: true } })
$set with a dotted path creates the intermediate subdocuments, so
{ $set: { "a.b.c": 1 } } on an empty document produces { a: { b: { c: 1 } } }. That
convenience is also how typos silently create a second, misspelled field.
Upsert and $setOnInsert
upsert: true inserts when the filter matches nothing. The new document is built from
the equality clauses of the filter plus the update operators.
db.counters.updateOne( { _id: "orders", period: "2026-01" }, { $inc: { seq: 1 }, $setOnInsert: { created_at: new Date() } }, { upsert: true } ) // { matchedCount: 0, modifiedCount: 0, upsertedCount: 1, // upsertedId: ... } on the first call
| Rule | Detail |
|---|---|
| Equality filters seed the document | { _id: "orders", period: "2026-01" } is copied in |
| Non-equality filters do not | { qty: { $gt: 5 } } contributes nothing to the insert |
$setOnInsert runs on insert only | Perfect for created_at |
$set runs on both paths | So updated_at belongs in $set |
| Concurrent upserts can collide | Two racing upserts can raise a duplicate key error. Put a unique index on the filter fields and retry once |
Array operators
| Operator | Does |
|---|---|
$push | Append. Duplicates allowed |
$push + $each | Append several values |
$push + $each + $slice | Append then trim. Negative keeps the last n, positive the first n, 0 empties |
$push + $each + $sort | Sort the array after appending. $sort: { score: -1 } for documents, $sort: -1 for scalars |
$push + $each + $position | Insert at an index instead of the end |
$addToSet | Append only if not already present. $each adds several |
$pop | 1 removes the last element, -1 the first |
$pull | Remove every element matching a query condition |
$pullAll | Remove every element exactly equal to one of these values |
// A capped, sorted leaderboard in one write db.players.updateOne({ _id: 1 }, { $push: { scores: { $each: [ { v: 88, at: new Date() } ], $sort: { v: -1 }, $slice: 10 } } }) db.posts.updateOne({ _id: 1 }, { $addToSet: { tags: { $each: ["mongodb", "mql"] } } }) db.posts.updateOne({ _id: 1 }, { $pull: { comments: { spam: true } } }) db.posts.updateOne({ _id: 1 }, { $pull: { scores: { $lt: 50 } } }) db.posts.updateOne({ _id: 1 }, { $pullAll: { tags: ["old", "draft"] } }) db.posts.updateOne({ _id: 1 }, { $pop: { events: -1 } })
$slice and $sort are only legal alongside $each, even when you push one value.
Modifiers apply in a fixed order: $each, then $sort, then $slice. $addToSet
compares whole values, so two subdocuments differing only in field order count as
different elements.
Positional operators
Three ways to say which element. Pick the wrong one and you update everything or nothing.
| Operator | Updates | Needs |
|---|---|---|
$ | The first element matched by the query | The array field must appear in the filter |
$[] | Every element | Nothing |
$[<id>] | Every element matching an arrayFilters entry | arrayFilters (3.6+) |
// First matching element only db.orders.updateOne( { _id: 1, "items.sku": "A-1" }, { $set: { "items.$.qty": 5 } } ) // Every element db.orders.updateOne({ _id: 1 }, { $inc: { "items.$[].qty": 1 } }) // Every element that matches a condition db.orders.updateOne( { _id: 1 }, { $set: { "items.$[low].backorder": true } }, { arrayFilters: [ { "low.qty": { $lt: 2 } } ] } ) // Nested arrays, one identifier per level db.orders.updateOne( { _id: 1 }, { $set: { "items.$[i].tags.$[t].hot": true } }, { arrayFilters: [ { "i.sku": "A-1" }, { "t": "sale" } ] } )
$ updates one element and there is no "update all matching" form of it, which is why
arrayFilters exists. An identifier must start with a lowercase letter and contain
only alphanumerics, and every identifier you use must appear in arrayFilters or the
server rejects the update. $ also cannot be used on the insert half of an upsert.
Update with an aggregation pipeline
Since 4.2 the update can be an array of stages, which is the only way to compute a field from other fields of the same document without reading it first.
db.orders.updateMany({}, [ { $set: { total: { $sum: "$items.subtotal" }, item_count: { $size: "$items" }, slug: { $toLower: { $concat: ["$brand", "-", "$sku"] } } } }, { $set: { needs_review: { $gt: ["$total", 1000] } } }, { $unset: "legacy_total" } ])
Only these stages are allowed: $addFields / $set, $project / $unset,
$replaceRoot / $replaceWith. Field paths use "$field" on the right-hand side,
which is exactly what plain $set cannot do.
Replacement is not an update
A plain document without operators is a replacement, and modern drivers refuse it in
updateOne rather than doing it silently.
db.orders.updateOne({ _id: 1 }, { status: "paid" }) // Error: update document requires atomic operators db.orders.replaceOne({ _id: 1 }, { status: "paid" }) // Document is now exactly { _id: 1, status: "paid" }. Everything else is gone.
_id is immutable, so it survives a replacement and cannot be changed by one. The
retired db.collection.update() helper accepted either shape, which is how whole
documents used to get wiped by accident. Use updateOne, updateMany, or
replaceOne and the ambiguity disappears.