Updating documents
Updating is the operation people most often get subtly wrong, because the obvious mental model - “find the document and overwrite a field” - is not how MongoDB works. An update is two documents: a filter that selects which documents to change, and an update document built from operators that describe the change. The operators are the whole point. They let you bump a number, set a field, remove a field, or push onto an array without touching anything else in the document. To replace a document wholesale you use a different method, replaceOne, and the driver refuses to let you mix the two up (proved below).
We keep editing our library members. Every output below was captured on mongo 8.3.11, mongosh 2.11.1 and Node driver 7.7.0 (Python, Go and Rust tabs were each run once in a scratch program on the same server); ids will differ on your machine.
The data we update
Section titled “The data we update”Each lesson seeds its own data:
db.members.insertMany([ { name: "Edsger", joined: 2022, fines: 5 }, { name: "Linus", joined: 2023, fines: 2 }, { name: "Margaret", joined: 2023, fines: 0 }, { name: "Grace", joined: 2024, fines: 0, city: "Arlington", borrowed: ["Compilers"] },]);assert.strictEqual(db.members.countDocuments(), 4);await db.collection("members").insertMany([ { name: "Edsger", joined: 2022, fines: 5 }, { name: "Linus", joined: 2023, fines: 2 }, { name: "Margaret", joined: 2023, fines: 0 }, { name: "Grace", joined: 2024, fines: 0, city: "Arlington", borrowed: ["Compilers"] },]);if ((await db.collection("members").countDocuments()) !== 4) throw new Error("expected 4");updateOne with $set
Section titled “updateOne with $set”$set writes a value into a field. If the field exists it is overwritten; if it does not exist it is created. updateOne applies the change to the first document that matches the filter and stops there. Here we record that Edsger paid off his fines:
const res = db.members.updateOne( { name: "Edsger" }, { $set: { fines: 0 } });console.log(res);console.log(db.members.updateOne({ name: "Edsger" }, { $set: { fines: 0 } })); // the very same update againconsole.log(db.members.updateOne({ name: "Nobody" }, { $set: { fines: 0 } })); // no matchassert.strictEqual(res.modifiedCount, 1);const res = await db.collection("members").updateOne( { name: "Edsger" }, { $set: { fines: 0 } });console.log(res);console.log(res.matchedCount, res.modifiedCount);if (res.modifiedCount !== 1) throw new Error("expected 1 modified");res = db.members.update_one( {"name": "Edsger"}, {"$set": {"fines": 0}},)print(res.matched_count, res.modified_count)res, err := coll.UpdateOne( ctx, bson.M{"name": "Edsger"}, bson.M{"$set": bson.M{"fines": 0}},)if err != nil { return err}fmt.Println(res.MatchedCount, res.ModifiedCount)let res = coll .update_one( doc! { "name": "Edsger" }, doc! { "$set": { "fines": 0 } }, ) .await?;println!("{} {}", res.matched_count, res.modified_count);Before the update Edsger owed five ({ name: 'Edsger', joined: 2022, fines: 5 }); after it only fines changed (fines: 0). The acknowledgement tells you how the operation went. matchedCount is how many documents the filter found; modifiedCount is how many actually changed. The three shell calls above printed (the ack shape is the same for the first, second and third; only the counts differ):
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 } <- first run{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 0, upsertedCount: 0 } <- same update again{ acknowledged: true, insertedId: null, matchedCount: 0, modifiedCount: 0, upsertedCount: 0 } <- no such memberIn the Node driver the result is { acknowledged: true, modifiedCount: 1, upsertedId: null, upsertedCount: 0, matchedCount: 1 } and the other tabs expose the same two counts (matched_count, MatchedCount, matched_count). Note the second line: run the very same update again and matchedCount stays 1 but modifiedCount is 0 - the document matched but nothing needed changing. The third: a filter that matches nothing is not an error, just matchedCount: 0. The shell’s insertedId: null is a leftover field of the update acknowledgement; ignore it.
$inc, and adding new fields
Section titled “$inc, and adding new fields”$inc adds a number to a field (use a negative number to subtract). It is atomic, which is the right tool for counters and balances. $set doubles as a way to add a field that was never there - here we both charge a late fee and stamp the member with a lastSeen year in one call:
db.members.updateOne( { name: "Linus" }, { $inc: { fines: 3 }, $set: { lastSeen: 2026 } });console.log(db.members.findOne({ name: "Linus" }, { _id: 0 }));assert.strictEqual(db.members.findOne({ name: "Linus" }).fines, 5);await db.collection("members").updateOne( { name: "Linus" }, { $inc: { fines: 3 }, $set: { lastSeen: 2026 } });const linus = await db.collection("members").findOne({ name: "Linus" }, { projection: { _id: 0 } });console.log(linus);if (linus.fines !== 5 || linus.lastSeen !== 2026) throw new Error("unexpected Linus");db.members.update_one( {"name": "Linus"}, {"$inc": {"fines": 3}, "$set": {"lastSeen": 2026}},)_, err := coll.UpdateOne( ctx, bson.M{"name": "Linus"}, bson.M{ "$inc": bson.M{"fines": 3}, "$set": bson.M{"lastSeen": 2026}, },)if err != nil { return err}coll.update_one( doc! { "name": "Linus" }, doc! { "$inc": { "fines": 3 }, "$set": { "lastSeen": 2026 } },).await?;Before, Linus had two fines and no lastSeen field; after, the count went up by three and a brand-new field appeared:
{ name: 'Linus', joined: 2023, fines: 5, lastSeen: 2026 }Two behaviours worth knowing, both proven: $inc on a field that does not exist starts from zero ({ _id: 1 } -> { _id: 1, visits: 1 }), and $inc on a non-numeric field fails loudly:
MongoServerError: Plan executor error during update :: caused by :: Cannot apply $inc to a value of non-numeric type. {_id: ObjectId('...')} has the field 'name' of non-numeric type stringRemoving a field with $unset
Section titled “Removing a field with $unset”To delete a field entirely - not set it to null, but make it stop existing - use $unset. The value you give it is ignored; only the field name matters. Here we strip the lastSeen field back off Linus:
db.members.updateOne({ name: "Linus" }, { $set: { lastSeen: 2026 } });db.members.updateOne( { name: "Linus" }, { $unset: { lastSeen: "" } });console.log(db.members.findOne({ name: "Linus" }, { _id: 0 }));assert.strictEqual("lastSeen" in db.members.findOne({ name: "Linus" }), false);await db.collection("members").updateOne( { name: "Linus" }, { $unset: { lastSeen: "" } });const linus = await db.collection("members").findOne({ name: "Linus" }, { projection: { _id: 0 } });console.log(linus);if ("lastSeen" in linus) throw new Error("lastSeen should be gone");db.members.update_one( {"name": "Linus"}, {"$unset": {"lastSeen": ""}},)_, err := coll.UpdateOne( ctx, bson.M{"name": "Linus"}, bson.M{"$unset": bson.M{"lastSeen": ""}},)if err != nil { return err}coll.update_one( doc! { "name": "Linus" }, doc! { "$unset": { "lastSeen": "" } },).await?;The lastSeen field is gone again, and nothing else moved:
{ name: 'Linus', joined: 2023, fines: 5 }Working with arrays: $push and $pull
Section titled “Working with arrays: $push and $pull”Documents often hold arrays, and you rarely want to replace the whole array to add or remove one element. $push appends an element; $pull removes every element matching a value. Imagine each member carries a borrowed array of book titles. First we lend a book to Margaret:
db.members.updateOne( { name: "Margaret" }, { $push: { borrowed: "Compilers" } });console.log(db.members.findOne({ name: "Margaret" }, { _id: 0 }));db.members.updateOne( { name: "Margaret" }, { $pull: { borrowed: "Compilers" } });console.log(db.members.findOne({ name: "Margaret" }, { _id: 0 }));assert.deepStrictEqual(Array.from(db.members.findOne({ name: "Margaret" }).borrowed), []);const members = db.collection("members");await members.updateOne( { name: "Margaret" }, { $push: { borrowed: "Compilers" } });console.log((await members.findOne({ name: "Margaret" }, { projection: { _id: 0 } })));await members.updateOne( { name: "Margaret" }, { $pull: { borrowed: "Compilers" } });const m = await members.findOne({ name: "Margaret" }, { projection: { _id: 0 } });console.log(m);if (m.borrowed.length !== 0) throw new Error("borrowed should be empty");db.members.update_one( {"name": "Margaret"}, {"$push": {"borrowed": "Compilers"}},)db.members.update_one( {"name": "Margaret"}, {"$pull": {"borrowed": "Compilers"}},)_, err := coll.UpdateOne( ctx, bson.M{"name": "Margaret"}, bson.M{"$push": bson.M{"borrowed": "Compilers"}},)if err != nil { return err}_, err = coll.UpdateOne( ctx, bson.M{"name": "Margaret"}, bson.M{"$pull": bson.M{"borrowed": "Compilers"}},)if err != nil { return err}coll.update_one( doc! { "name": "Margaret" }, doc! { "$push": { "borrowed": "Compilers" } },).await?;coll.update_one( doc! { "name": "Margaret" }, doc! { "$pull": { "borrowed": "Compilers" } },).await?;If the borrowed field did not exist yet, $push creates it as a one-element array, and $pull removes the element by value, leaving an empty array (not a missing field):
{ name: 'Margaret', joined: 2023, fines: 0, borrowed: [ 'Compilers' ] }{ name: 'Margaret', joined: 2023, fines: 0, borrowed: [] }$push on a field that is not an array fails: The field 'visits' must be an array but is of type int in document {_id: 1}. Two near-relatives: $addToSet appends only if the value is not already there, and $push with $each + $slice keeps a bounded window - tags: ['a','b'] pushed with { $each: ['c','d','e'], $slice: -3 } became [ 'c', 'd', 'e' ].
updateMany: changing every match
Section titled “updateMany: changing every match”updateMany is the same call as updateOne, but it applies the change to every document the filter selects. Here we forgive one fine for everyone who currently owes something - note the negative $inc:
const res = db.members.updateMany( { fines: { $gt: 0 } }, { $inc: { fines: -1 } });console.log(res);assert.strictEqual(res.matchedCount, res.modifiedCount);const res = await db.collection("members").updateMany( { fines: { $gt: 0 } }, { $inc: { fines: -1 } });console.log(res.matchedCount, res.modifiedCount);if (res.matchedCount !== res.modifiedCount) throw new Error("expected every match modified");res = db.members.update_many( {"fines": {"$gt": 0}}, {"$inc": {"fines": -1}},)print(res.matched_count, res.modified_count)res, err := coll.UpdateMany( ctx, bson.M{"fines": bson.M{"$gt": 0}}, bson.M{"$inc": bson.M{"fines": -1}},)if err != nil { return err}fmt.Println(res.MatchedCount, res.ModifiedCount)let res = coll .update_many( doc! { "fines": { "$gt": 0 } }, doc! { "$inc": { "fines": -1 } }, ) .await?;println!("{} {}", res.matched_count, res.modified_count);The counts reflect everyone who was touched in this single operation. After the earlier steps only Linus (5 fines) still owes anything, so the ack was matchedCount: 1, modifiedCount: 1 and his fines dropped to 4. With several matches both numbers are the number of documents changed.
In Compass: hover a document in the Documents tab, click the pencil icon, edit the values and click Update. Per the Compass documentation (modify documents, fetched 2026-09-30) the List/Table views send a findOneAndUpdate (only the fields you changed) while the JSON view sends a findOneAndReplace (the entire document) - the same update-versus-replace distinction as below. Compass edits one document at a time; for many, use updateMany in the embedded shell.
Replacing a whole document: replaceOne
Section titled “Replacing a whole document: replaceOne”The big trap for people coming from other systems: what if you pass updateOne a plain document with no operators, the same shape you would insert? The classic textbook answer - “MongoDB treats it as a full replacement” - is not what modern drivers do. Both mongosh 2.11.1 and the Node driver 7.7.0 refuse:
db.people.insertOne({ name: "Grace", joined: 2024, fines: 0, city: "Arlington", borrowed: ["Compilers"] });
// 1. A bare document handed to updateOne: rejected before it reaches the serverlet err;try { db.people.updateOne({ name: "Grace" }, { name: "Grace H.", fines: 9 }); } catch (e) { err = e; }console.log(err.name + ": " + err.message);console.log(db.people.findOne({ name: "Grace" }, { _id: 0 })); // untouched
// 2. replaceOne is the method that replacesconsole.log(db.people.replaceOne({ name: "Grace" }, { name: "Grace H.", fines: 9 }));console.log(db.people.findOne({ name: "Grace H." }));
// 3. ...and the two must not be mixed uplet e2, e3;try { db.people.replaceOne({ name: "Grace H." }, { $set: { a: 1 } }); } catch (e) { e2 = e; }try { db.people.updateOne({ name: "Grace H." }, { $set: { _id: 5 } }); } catch (e) { e3 = e; }console.log(e2.name + ": " + e2.message);console.log(e3.name + ": " + e3.message);assert.strictEqual(err.message, "Update document requires atomic operators");MongoInvalidArgumentError: Update document requires atomic operators{ name: 'Grace', joined: 2024, fines: 0, city: 'Arlington', borrowed: [ 'Compilers' ]}{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0}{ _id: ObjectId('6abca92eacc4516d11798c60'), name: 'Grace H.', fines: 9}MongoInvalidArgumentError: Replacement document must not contain atomic operatorsMongoServerError: Plan executor error during update :: caused by :: Performing an update on the path '_id' would modify the immutable field '_id'So: updateOne/updateMany need operators (or an aggregation pipeline, below) - a bare document throws Update document requires atomic operators. replaceOne replaces everything except _id: joined, city and borrowed were gone afterwards and only name and fines remain, which is exactly why you should not reach for it to “just change a name”. _id is immutable either way. Every driver guards the same way, each in its own words - we ran a bare-document update_one/UpdateOne/update_one in each:
Node MongoInvalidArgumentError: Update document requires atomic operatorsPython ValueError: update only works with $ operatorsGo update document must contain key beginning with '$'Rust An invalid argument was provided: update document must only contain update modifiersThe server itself still treats a non-operator document as a replacement: sending the raw command db.runCommand({ update: "people", updates: [{ q: { name: "Grace" }, u: { name: "Grace H." } }] }) returned n: 1, nModified: 1, ok: 1, and the stored document became { name: 'Grace H.' } - joined and city gone. So it is the drivers and mongosh that guard you; if you speak the wire protocol yourself, or use an old driver, you can still meet the historical replace-by-accident behaviour.
Updating array elements: positional $, $[], $[id]
Section titled “Updating array elements: positional $, $[], $[id]”Arrays of sub-documents are the bread and butter of real schemas. Three positional forms address elements without you knowing their index (data: Ada has two loans, Grace one):
db.loans.insertMany([ { member: "Ada", books: [{ title: "Compilers", renewed: 0 }, { title: "Algorithms", renewed: 1 }] }, { member: "Grace", books: [{ title: "Compilers", renewed: 2 }] },]);// $ : the first element matched by the FILTERdb.loans.updateOne({ member: "Ada", "books.title": "Compilers" }, { $inc: { "books.$.renewed": 1 } });console.log(db.loans.findOne({ member: "Ada" }, { _id: 0 }).books);// $[id] with arrayFilters: every element matching a conditiondb.loans.updateOne({ member: "Ada" }, { $inc: { "books.$[b].renewed": 10 } }, { arrayFilters: [{ "b.renewed": { $gte: 1 } }] });console.log(db.loans.findOne({ member: "Ada" }, { _id: 0 }).books);// $[] : every elementdb.loans.updateMany({}, { $set: { "books.$[].seen": true } });console.log(db.loans.find({}, { _id: 0 }).toArray().map((d) => d.member + ":" + d.books.every((b) => b.seen)));// $ without an array match in the filter -> errorlet err;try { db.loans.updateOne({ member: "Ada" }, { $inc: { "books.$.renewed": 1 } }); } catch (e) { err = e; }console.log(err.message);assert.match(err.message, /positional operator did not find the match/);[ { title: 'Compilers', renewed: 1 }, { title: 'Algorithms', renewed: 1 } ][ { title: 'Compilers', renewed: 11 }, { title: 'Algorithms', renewed: 11 } ][ 'Ada:true', 'Grace:true' ]Plan executor error during update :: caused by :: The positional operator did not find the match needed from the query.books.$ needs the array to appear in the filter ("books.title": "Compilers"); without it the server has no idea which element you mean. $[id] + arrayFilters is the general form; $[] touches all.
findOneAndUpdate: change and read in one step
Section titled “findOneAndUpdate: change and read in one step”updateOne returns counts, not the document. When you need the document (a queue, a counter that returns its new value) use findOneAndUpdate, which is atomic - and note the default returns the document as it was before the update:
db.counters.insertOne({ _id: "orders", n: 41 });const before = db.counters.findOneAndUpdate({ _id: "orders" }, { $inc: { n: 1 } });const after = db.counters.findOneAndUpdate({ _id: "orders" }, { $inc: { n: 1 } }, { returnDocument: "after" });const miss = db.counters.findOneAndUpdate({ _id: "nope" }, { $inc: { n: 1 } });console.log(before, after, miss);assert.strictEqual(before.n, 41);assert.strictEqual(after.n, 43);assert.strictEqual(miss, null);{ _id: 'orders', n: 41 } { _id: 'orders', n: 43 } null(Node driver 7.7.0: findOneAndUpdate(filter, update, { returnDocument: "after" }) returned the document itself - { name: 'Grace', joined: 2025 } - and null on a miss.)
Finally, an update can also be an aggregation pipeline (an array of stages), which lets one field be computed from others:
db.loans.insertOne({ member: "Alan", books: [{ renewed: 2 }, { renewed: 3 }] });db.loans.updateOne({ member: "Alan" }, [{ $set: { totalRenewals: { $sum: "$books.renewed" } } }]);console.log(db.loans.findOne({ member: "Alan" }).totalRenewals);assert.strictEqual(db.loans.findOne({ member: "Alan" }).totalRenewals, 5);5Tips and gotchas
Section titled “Tips and gotchas”- The bare-document trap, corrected: modern drivers throw
Update document requires atomic operatorsforupdateOne(filter, { plain: "document" })- they do not silently replace. If you want replacement, callreplaceOne, and remember it drops every field you did not include. matchedCountandmodifiedCountcan differ. A document can match the filter yet not be modified, because the new value equals the old one (matchedCount: 1, modifiedCount: 0above).- A filter matching nothing is not an error:
matchedCount: 0. Check the counts if “nothing happened” would be a bug. updateManyhas no built-in limit - it changes every match. Double-check the filter before running one.$setcreates missing fields,$unsetremoves them, and$incworks even when the field is absent (it starts from zero) but fails on a non-numeric field.- Two operators cannot touch the same path in one update:
{ $set: { fines: 1 }, $inc: { fines: 1 } }fails withUpdating the path 'fines' would create a conflict at 'fines'.
Reading AI-generated code
Section titled “Reading AI-generated code”Assistants trained on older MongoDB material still describe the pre-driver-guard behaviour:
🐛 Spot the bug
// AI comment: "updateOne with a plain document sets those fields and keeps the rest"async function renameMember(db, id, newName) {return db.collection("members").updateOne({ _id: id }, { name: newName });}Show the answer
Neither half of that comment is true. On mongosh 2.11.1 and Node driver 7.7.0 the call throws MongoInvalidArgumentError: Update document requires atomic operators - the first request in production dies. The tempting “fix” is replaceOne({ _id: id }, { name: newName }), which runs, but replaces the whole document: in the run above joined, city and borrowed vanished and only the new name (and _id) survived. The correct fix wraps the change in $set:
const members = db.collection("members");const { insertedId } = await members.insertOne({ name: "Grace", joined: 2024, fines: 0, city: "Arlington" });
let threw = null;try { await members.updateOne({ _id: insertedId }, { name: "Grace H." }); } catch (e) { threw = e.message; }console.log("bare document:", threw);
await members.updateOne({ _id: insertedId }, { $set: { name: "Grace H." } });const after = await members.findOne({ _id: insertedId }, { projection: { _id: 0 } });console.log("$set :", after);if (threw !== "Update document requires atomic operators") throw new Error("expected the guard error");if (after.name !== "Grace H." || after.city !== "Arlington") throw new Error("$set should keep the other fields");bare document: Update document requires atomic operators$set : { name: 'Grace H.', joined: 2024, fines: 0, city: 'Arlington' }Mental Model
Section titled “Mental Model”An update is a program for one document: a filter chooses the target, and the operators ($set, $inc, $push, …) are instructions the server runs against that document atomically. replaceOne is the only “swap the whole thing” verb; everything else is a targeted edit. updateMany runs that program once per matching document - each run is atomic, the batch as a whole is not (the updateMany reference says exactly that).
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
updateOne | Affects a single document, precise and easy to reason about what changed | Must be called repeatedly to change many documents, costing multiple round trips |
updateMany | Changes many documents in one call, much faster at scale | Atomic only per document, not across documents - another reader can see a partially-updated state while it runs |
$set / operator updates | Changes only the fields you want; the rest of the document stays safe | You must always include an operator (the driver rejects a bare document) |
replaceOne | Swaps the whole document in one step; good for “document is the source of truth” writes | Drops every field you omit (joined, city, borrowed were lost above) |
findOneAndUpdate | Change and read atomically in one round trip | Returns the old document unless returnDocument: "after"; holds a write lock on that document like any update |
Common Misconceptions
Section titled “Common Misconceptions”- “
updateOne(filter, { plain: 'doc' })replaces the document.” - it throwsUpdate document requires atomic operatorsin mongosh and the Node driver.replaceOnereplaces. - “
replaceOneis a safe way to change one field.” - it deletes every field you leave out. - “A no-op update is an error.” -
matchedCount: 1, modifiedCount: 0is a normal answer; so ismatchedCount: 0. - “
updateManyis one atomic transaction.” - only each document write is atomic; use a multi-document transaction for all-or-nothing. - “
findOneAndUpdatereturns the new document.” - by default it returns the old one; ask forreturnDocument: "after". - “Looping
updateOneand oneupdateManyare the same.” -updateManydoes it in one round trip.
Real-world example
The
updateManyreference is explicit about the guarantee: “updateMany()modifies each document individually. Each document write is an atomic operation, butupdateMany()as a whole is not atomic”, and it can be used inside distributed transactions (db.collection.updateMany, consulted 2026-09-30).
Python/Go/Rust tabs mirror the verified shell/Node behaviour; each was run once in a scratch program against the same server and produced the counts shown (they are not executed by the course’s automated checker).