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:

  1. The query planner evaluates the filter and, if a usable index exists, performs an index scan (IXSCAN) to find candidate documents; otherwise it falls back to a full collection scan (COLLSCAN).
  2. updateOne() stops as soon as it finds the first matching document. updateMany() continues consuming the cursor of matches until it’s exhausted.
  3. For each matched document, the update operators are applied in memory to compute the new document state.
  4. 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.
  5. If upsert: true was 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 as upsertedCount: 1 and a populated upsertedId.
  6. 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 through find() (or countDocuments()) first to confirm exactly which and how many documents it targets.
  • Always inspect matchedCount and modifiedCount in 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 with explain("executionStats") to confirm an IXSCAN rather than a COLLSCAN.
  • Use bulkWrite() instead of many separate updateOne() 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: true is set; a typo in the filter can silently insert an unwanted duplicate document instead of updating the one you meant.

Practice Exercises

  1. In a db.products collection, mark every product with stock: 0 as { inStock: false }. Use countDocuments() first to know what matchedCount to expect, then run the update with the correct method.
  2. In db.customers, update just one customer’s email field by their _id, where the id arrives as a string (as if from a web request). Make sure matchedCount is exactly 1.
  3. In db.orders, write an update that changes status: "processing" to status: "shipped" for every matching order. Run it once, note modifiedCount, then run the exact same command again and predict what modifiedCount will 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 matchedCount and modifiedCount can 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 with explain() on large collections.
  • Comparing a string to an ObjectId in a filter is a classic source of silent zero-match updates.