updateOne and updateMany
Once documents exist in a collection, you rarely want to throw them away and reinsert them just to change one field. MongoDB gives you updateOne and updateMany for exactly that: modifying part of one or many existing documents in place, without touching the fields you don’t mention. Understanding the difference between the two methods, and what the update document you pass them actually means, is one of the most important skills for working with MongoDB safely.
Overview: How updateOne and updateMany Work
Both methods take the same basic shape: a filter that selects which document(s) to touch, and an update document that describes what to change. The difference is scope. updateOne() finds documents matching the filter and modifies only the first one it encounters (based on natural or index order), even if many documents match. updateMany() modifies every document that matches the filter. This mirrors the old update() method’s multi option, except now the method name itself tells you the scope — there’s no boolean flag to forget.
Crucially, the second argument is normally an update document made of atomic operators like $set, $inc, or $unset (covered in depth in the next lesson), not a plain replacement object. When you write { $set: { status: "shipped" } }, MongoDB only touches the status field on the matched document(s) — every other field is left exactly as it was. This is fundamentally different from replaceOne(), which swaps out the entire document body (except _id) for whatever you provide. updateOne() technically also accepts a plain replacement document (no operators) as a convenience, behaving like replaceOne() in that case, but updateMany() does not allow this — it requires operator-based updates (or an aggregation pipeline as the update), since replacing many documents with one literal object rarely makes sense.
Under the hood, a single document update is always atomic: no other client can ever read a document that’s half-updated. That’s true whether you touch one field or ten with $set. This is why MongoDB says single-document writes are ACID by default — you only need a multi-document transaction when an update must succeed or fail together with changes to other documents or collections.
Both methods return an object describing what happened, not the documents themselves: acknowledged, matchedCount (how many documents matched the filter), modifiedCount (how many were actually changed — this can be lower than matchedCount if a document already had the target value), upsertedCount, and upsertedId if an upsert inserted a new document. If you need the updated document returned in the same call, use findOneAndUpdate() instead, which is covered in its own lesson.
Syntax
db.collection.updateOne(
<filter>,
<update>,
{
upsert: <boolean>,
arrayFilters: [ <filterDocument1>, ... ],
collation: <document>,
hint: <document or string>
}
);
db.collection.updateMany(
<filter>,
<update>,
{
upsert: <boolean>,
arrayFilters: [ <filterDocument1>, ... ],
collation: <document>,
hint: <document or string>
}
);
| Parameter | Meaning |
|---|---|
filter |
A query document selecting which document(s) to update. Uses the same query operators as find() ($gt, $in, $and, etc.). |
update |
An object of update operators ($set, $inc, $unset, …) describing the change, or an aggregation pipeline (an array of stages) for computed updates. For updateOne() only, a plain document with no operators is allowed and acts as a full replacement. |
upsert |
If true and nothing matches the filter, insert a new document built from the filter’s equality fields plus the update. Defaults to false. |
arrayFilters |
Identifies which array elements to modify when the update uses a positional filtered operator like $[elem]. |
collation |
Language-specific string comparison rules (e.g. case-insensitive matching) for the filter. |
hint |
Forces the query planner to use a specific index for the filter, bypassing its own choice. |
Examples
Example 1: Updating a single document with updateOne
Suppose db.users holds user accounts and one user just logged in.
db.users.updateOne(
{ email: "alice@example.com" },
{ $set: { status: "active", lastLogin: new Date() } }
);
Output:
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
MongoDB located the single document whose email matches, and changed only status and lastLogin on it. Every other field on that user — name, password hash, preferences — is untouched. matchedCount: 1 confirms exactly one document was found, and modifiedCount: 1 confirms it was actually changed.
Example 2: Updating every matching document with updateMany
Now suppose a shipment carrier is delayed and every pending order needs to be marked as delayed.
db.orders.updateMany(
{ status: "pending", carrier: "NorthStar Freight" },
{ $set: { status: "delayed" } }
);
Output:
{
acknowledged: true,
insertedId: null,
matchedCount: 42,
modifiedCount: 42,
upsertedCount: 0
}
Unlike updateOne(), this touched all 42 orders that matched both filter conditions. If this collection is large, that filter should be backed by an index on status (and ideally a compound index including carrier) so MongoDB doesn’t have to scan every document to find the matches.
Example 3: Why matchedCount and modifiedCount can differ
Run the same update twice in a row to see an important nuance:
db.orders.updateMany(
{ status: "delayed" },
{ $set: { status: "delayed" } }
);
Output:
{
acknowledged: true,
insertedId: null,
matchedCount: 42,
modifiedCount: 0
}
All 42 documents still matched the filter, but none of them actually changed, because their status field was already "delayed" — MongoDB detects there’s nothing new to write and skips the modification. This is why relying on matchedCount alone to confirm “my update worked” is a mistake; always check modifiedCount when you specifically care whether a value changed.
How updateOne and updateMany Work Step by Step
Internally, both methods go through the same pipeline:
- The query planner evaluates the
filterand, if a usable index exists, performs an index scan (IXSCAN) to find candidate documents; otherwise it falls back to a full collection scan (COLLSCAN). updateOne()stops as soon as it finds the first matching document.updateMany()continues consuming the cursor of matches until it’s exhausted.- For each matched document, the update operators are applied in memory to compute the new document state.
- The new state is written back to storage. Because this happens per document, and MongoDB never exposes a half-applied write, each individual document’s update is atomic even though a multi-document
updateMany()as a whole is not one single atomic transaction across all matched documents. - If
upsert: truewas set and no document matched, MongoDB constructs a brand-new document from the filter’s equality conditions plus the update operators, and inserts it — reflected asupsertedCount: 1and a populatedupsertedId. - The server acknowledges the write according to the active write concern (by default, acknowledgment from the primary) before the driver resolves the returned result object.
Common Mistakes
Mistake 1: Using updateOne when you meant updateMany
This is the single most common bug with MongoDB updates — it fails silently, with no error.
// Intends to cancel every pending order, but only cancels ONE
db.orders.updateOne(
{ status: "pending" },
{ $set: { status: "cancelled" } }
);
This runs without error and reports matchedCount: 1, which looks fine unless you specifically check that number against how many pending orders you expected. The fix is simply to use the method that matches your intent:
db.orders.updateMany(
{ status: "pending" },
{ $set: { status: "cancelled" } }
);
Mistake 2: Passing a replacement document to updateMany
updateMany() requires operator-based updates. Passing a plain field/value object (no $ operators) throws an error rather than silently replacing every matched document:
db.orders.updateMany(
{ status: "pending" },
{ status: "cancelled" }
);
// MongoServerError: multi update only works with $ operators
Wrap the change in $set:
db.orders.updateMany(
{ status: "pending" },
{ $set: { status: "cancelled" } }
);
Mistake 3: Comparing a string to an ObjectId in the filter
_id fields are stored as the BSON ObjectId type, not strings. An id pulled from a URL parameter or request body arrives as a plain string, and comparing it directly against _id matches nothing:
const id = "65f1a2b3c4d5e6f7a8b9c0d1"; // e.g. from req.params.id
db.users.updateOne(
{ _id: id },
{ $set: { verified: true } }
);
// matchedCount: 0 -- id is a string, _id is an ObjectId
Convert the string to an ObjectId before querying:
const { ObjectId } = require("mongodb");
const id = "65f1a2b3c4d5e6f7a8b9c0d1";
db.users.updateOne(
{ _id: new ObjectId(id) },
{ $set: { verified: true } }
);
Best Practices
- Before running an
updateMany()in production, run the same filter throughfind()(orcountDocuments()) first to confirm exactly which and how many documents it targets. - Always inspect
matchedCountandmodifiedCountin the result — a mismatch or a lower-than-expected count is often the first sign something is wrong. - Prefer operator-based updates (
$set, etc.) over whole-document replacement so an unrelated field can never be accidentally wiped out. - Make sure the filter fields used in a large
updateMany()are backed by an index; check withexplain("executionStats")to confirm anIXSCANrather than aCOLLSCAN. - Use
bulkWrite()instead of many separateupdateOne()calls when you need to apply several different updates in one network round trip. - Reach for a multi-document transaction only when an update must succeed or fail together with changes to other documents or collections — a single
updateOne()/updateMany()call is already atomic per document. - Double-check filters whenever
upsert: trueis set; a typo in the filter can silently insert an unwanted duplicate document instead of updating the one you meant.
Practice Exercises
- In a
db.productscollection, mark every product withstock: 0as{ inStock: false }. UsecountDocuments()first to know whatmatchedCountto expect, then run the update with the correct method. - In
db.customers, update just one customer’semailfield by their_id, where the id arrives as a string (as if from a web request). Make surematchedCountis exactly1. - In
db.orders, write an update that changesstatus: "processing"tostatus: "shipped"for every matching order. Run it once, notemodifiedCount, then run the exact same command again and predict whatmodifiedCountwill be the second time — and why.
Summary
updateOne()modifies only the first document matching the filter;updateMany()modifies every matching document.- The update argument should be built from atomic operators like
$set, not a plain replacement object —updateMany()rejects plain replacements outright. - The result object’s
matchedCountandmodifiedCountcan differ: a document can match the filter but not actually change if its value was already correct. - Every single-document update is atomic, regardless of how many fields it touches; multi-document atomicity requires an explicit transaction.
- A missing index on the filter fields turns
updateMany()into a full collection scan — always verify withexplain()on large collections. - Comparing a string to an
ObjectIdin a filter is a classic source of silent zero-match updates.
