MongoDB: How the updateOne() / updateMany() (UPDATE) Works Internally
Table of Contents
1. The Command We Will Follow
The UPDATE equivalent in MongoDB is updateOne() for a single document, updateMany() for multiple documents, and replaceOne() for a full document replacement. In this post we follow these commands step by step, using the students collection:
| MongoDB | Oracle |
|---|---|
| |
The command says: find the matching document(s) and modify specific fields (or replace the entire document). If no document matches, nothing happens (unless upsert: true is set).
Key difference from Oracle: MongoDB updates are auto-committed per operation. There is no separate COMMIT unless you use a multi-document transaction. Also, MongoDB uses update operators ($set, $inc, $unset, etc.) to modify specific fields without touching the rest of the document, whereas Oracle always specifies the full column list in the SET clause.
2. Overall Flowchart
This is the path of the update command from the client to the acknowledgment. Notice that an update has two major phases: a FIND phase (steps 4–6) to locate the documents, and a WRITE phase (steps 7–8) to modify them.
+----------------------------------------------+
| 1. CLIENT (mongosh / driver) |
| db.students.updateOne( |
| { name: "Ravi" }, |
| { $set: { grade: "A+" } } |
| ) |
+-----------------------+----------------------+
| update command (BSON over TCP)
v
+----------------------------------------------+
| 2. mongod: NETWORK LAYER AND SESSION |
| accepts the connection, creates an opid |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 3. AUTHORIZATION CHECK | NO ──▶ Unauthorized error (stop)
| does the user have the update action? |
+-----------------------+----------------------+
| YES
v
+==============================================+
║ FIND PHASE ║
+==============================================+
|
v
+----------------------------------------------+
| 4. PARSE AND VALIDATE | FAIL ──▶ error (stop)
| a) validate the filter (query) | e.g. unknown operator
| b) validate the update specification | e.g. conflicting paths
| c) check: operators OR replacement, | e.g. mixing $set with
| never both | a plain document
+-----------------------+----------------------+
| OK
v
+----------------------------------------------+
| 5. LOCK |
| intent-exclusive (IX) lock on DB and |
| collection |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 6. QUERY PLANNER + FIND MATCHING DOCS | uses the same planner as find()
| plan cache or trial run | IXSCAN or COLLSCAN
| locate the document(s) to update | for updateOne: stop after 1 match
+-----------------------+----------------------+
|
v
+==============================================+
║ WRITE PHASE ║
+==============================================+
|
v
+----------------------------------------------+
| 7. FOR EACH MATCHING DOCUMENT: |
| a) read current document from storage |
| b) apply update operators to produce |
| the new document |
| c) validate the NEW document | FAIL ──▶ validation error (stop)
| - schema validation rules | e.g. new age exceeds max
| - BSON size (max 16 MB) | e.g. duplicate unique key
| - unique index constraints |
| d) write updated document to collection |
| B-tree (WiredTiger cache) |
| e) update affected index B-trees |
| (delete old entries, insert new) |
| f) write to journal (WAL) |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 8. OPLOG ENTRY (replica set only) |
| write the delta to local.oplog.rs | $set → op: "u" with delta
| for $set/$inc: records the operators | replace → op: "u" with full doc
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 9. WAIT FOR WRITE CONCERN | w:1 ──▶ primary acknowledged
| w:1 (default), w:"majority", w:0 | w:majority ──▶ wait for secondaries
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 10. RELEASE LOCKS |
| release IX lock on collection and DB |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 11. RESPONSE TO CLIENT |
| { acknowledged: true, |
| matchedCount: 1, |
| modifiedCount: 1, |
| upsertedId: null } |
+-----------------------+----------------------+
No matching document?
matchedCount: 0, modifiedCount: 0
If upsert: true → an INSERT happens instead (see Section 7)
3. Step-by-Step Explanation
Step 1 — The client builds the command
mongosh turns updateOne() or updateMany() into an update command document that includes the collection name, an array of update statements (each with a filter q, an update specification u, and options like upsert and multi). It sends this to mongod as a BSON message. Like insertOne(), the command is sent immediately — no cursor is involved.
Step 2 — mongod receives the request
The network layer accepts the message and creates an operation context with an opid. You can see active updates by running db.currentOp().
Step 3 — Authorization check
MongoDB checks that the user’s roles include the update action on myFirstDB.students. If the privilege is missing, the command stops with a not authorized error.
Step 4 — Parse and validate the filter and update specification
This step is unique to updates and has two parts:
- Filter validation: The filter is parsed into a match expression tree, exactly like
find(). Unknown operators such as$greaterthanproduce an error. - Update specification validation: MongoDB checks the update document for correctness:
- You must use either update operators (
$set,$inc,$unset, etc.) or a replacement document (plain fields), never both. Mixing them produces an error:The update operation document must contain atomic operators. - Conflicting paths are rejected. For example,
{ $set: { "a.b": 1 }, $unset: { "a": "" } }fails becauseaanda.bconflict. - The
_idfield cannot be changed by any update operator. If the replacement document contains a different_id, the update fails. - Field names in the update cannot start with
$or contain.(same rules as insert).
- You must use either update operators (
Step 5 — Take locks
MongoDB takes intent-exclusive (IX) locks on the database and the collection. These are the same locks used by insertOne(). They allow concurrent operations on different documents but prevent conflicting schema changes on the collection.
Step 6 — Query planner finds the matching documents
This is where the update reuses the same query planner that find() uses (see the find() post, Section 4). MongoDB checks the plan cache for the filter’s query shape, or runs a trial of candidate plans. The winning plan (e.g., IXSCAN on name or COLLSCAN) is used to locate the matching documents.
- For
updateOne(), MongoDB stops scanning as soon as it finds one matching document. - For
updateMany(), MongoDB continues scanning until all matching documents are found. - If a sort is implied (e.g., you want to update the “oldest” student), you can use
findOneAndUpdate()with asortoption. The query planner then also considers the sort order.
Step 7 — Apply the update to each matching document
For each document found in Step 6, MongoDB performs the following sub-steps:
- Read the current document from the WiredTiger cache (or from disk if the page is not cached).
- Apply the update operators to produce the new version of the document. For example,
$set: { grade: "A+" }changes only thegradefield; all other fields remain untouched.$inc: { age: 1 }adds 1 to the currentagevalue. - Validate the resulting document:
- Schema validation rules (if defined) are checked against the new document, not the update specification. If the update would make the document invalid, the update fails.
- The new document must not exceed 16 MB.
- Unique index constraints are checked. MongoDB verifies that the new values do not conflict with any other document (the document being updated is excluded from the check).
- Write the updated document to the collection B-tree in the WiredTiger cache.
- Update affected indexes. For each index on the collection, MongoDB checks whether the update changed any indexed field. If yes, it deletes the old index entry and inserts a new one. If the indexed field was not changed, the index entry is left untouched (an important optimization).
- Write to the journal (WAL) for crash recovery.
Step 8 — Write the oplog entry (replica set only)
In a replica set, MongoDB writes an oplog entry to local.oplog.rs. The format depends on the type of update:
- Operator update (
$set,$inc, etc.): The oplog records the delta — the operators and their values. For example:{ $set: { grade: "A+" } }. This is compact and efficient for replication. - Replacement update (
replaceOne): The oplog records the full new document because the entire document was replaced.
The oplog entry type is "u" (update). Secondaries replay the same operators against the same document (matched by _id).
Step 9 — Wait for write concern
Same as insertOne(). The default w: 1 acknowledges as soon as the primary has written the data. w: "majority" waits for a majority of replica set members. j: true ensures the journal is flushed to disk.
Step 10 — Release locks
The IX locks on the collection and database are released. The updated document is now visible to other operations.
Step 11 — Send the response to the client
The response includes three important counters:
matchedCount: how many documents matched the filter.modifiedCount: how many documents were actually changed. If the new values are the same as the old values,modifiedCountis 0 even thoughmatchedCountis 1.upsertedId: ifupsert: truewas set and no document matched, this contains the_idof the newly inserted document. Otherwise, it isnull.
4. How the Storage Engine Handles the Update
This is Step 7d–7f in detail. WiredTiger uses MVCC (multi-version concurrency control), which means the old version of the document is kept in memory until no active snapshot needs it.
+----------------------------------------------+
| Application calls updateOne() |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| READ PHASE |
| WiredTiger: read the current document from |
| the collection B-tree (cache or disk) |
| MVCC: the reader sees a consistent snapshot |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| APPLY PHASE |
| MongoDB: apply $set, $inc, etc. to produce |
| the new version of the document in memory |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| WRITE PHASE (WiredTiger cache) |
| |
| Case A: Document size unchanged or smaller |
| ──▶ Update in place in the B-tree leaf |
| page. Fast. No relocation needed. |
| |
| Case B: Document grew significantly |
| ──▶ The old record is marked as deleted. |
| A new record is inserted into the |
| B-tree (possibly on a different page).|
| This is similar to Oracle row |
| migration, but handled transparently |
| by the B-tree structure. |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| INDEX MAINTENANCE |
| For each index on the collection: |
| - Did the indexed field change? |
| YES ──▶ delete old entry, insert new |
| NO ──▶ skip (optimization) |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| JOURNAL (WAL) |
| Write the redo record to the journal. |
| Flushed to disk every 100 ms (default) |
| or immediately if j:true. |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| MVCC CLEANUP (later) |
| When no active snapshot needs the old |
| version, WiredTiger reclaims the space. |
| This happens during eviction or checkpoint. |
+----------------------------------------------+
Key points:
- In-place vs relocation: If the update does not change the document size (or makes it smaller), WiredTiger updates the record in place within the B-tree leaf page. If the document grows beyond the available space on the page, WiredTiger inserts a new record and marks the old one as deleted. This is transparent to the application.
- MVCC: The old version of the document remains accessible to queries that started before the update (they have an older snapshot). This is similar to Oracle’s read consistency using undo segments, but WiredTiger stores the old versions in the B-tree itself rather than in a separate undo tablespace.
- Journal: The journal records the logical operation (the update operators or the full replacement), not the physical page changes. On crash recovery, WiredTiger replays the journal from the last checkpoint.
- Checkpoint: Every 60 seconds (by default), WiredTiger writes all dirty pages to the data files. After a checkpoint, old MVCC versions that are no longer needed can be reclaimed.
Comparison with Oracle:
| MongoDB (WiredTiger) | Oracle |
|---|---|
| Old version stored in B-tree (MVCC) | Old version stored in undo segment |
| In-place update if size is similar | In-place update within the data block |
| Document relocation if it grows too large | Row migration (forwarding pointer) or row chaining |
| Journal records logical operations | Redo log records physical block changes |
| Old versions reclaimed during eviction / checkpoint | Undo retained until undo_retention expires or space is needed |
5. Update Operators and Index Maintenance
MongoDB provides many update operators. Each operator modifies specific fields without affecting the rest of the document. Here are the most common ones and how they interact with indexes:
| Operator | What It Does | Index Impact |
|---|---|---|
$set | Sets the value of a field. Creates the field if it does not exist. | If the field is indexed, the old index entry is deleted and a new one is inserted. If the field is not indexed, no index work is needed. |
$unset | Removes a field from the document. | If the field is indexed, the old index entry is deleted. No new entry is inserted. |
$inc | Increments a numeric field by the given amount. | Same as $set — old entry deleted, new entry inserted if the field is indexed. |
$rename | Renames a field. | If the old or new name is indexed, both indexes are updated. Cannot rename _id. |
$push | Appends a value to an array field. | If the array field has a multikey index, new index entries are added for the new array elements. |
$pull | Removes matching values from an array field. | If the array field has a multikey index, index entries for the removed elements are deleted. |
$addToSet | Adds a value to an array only if it does not already exist. | Same as $push if the value is new. No index change if the value already exists. |
$min / $max | Updates the field only if the new value is less than / greater than the current value. | Index updated only if the field value actually changes. |
$currentDate | Sets the field to the current date or timestamp. | Same as $set if the field is indexed. |
Important optimization: MongoDB checks whether each indexed field actually changed before modifying the index. If you run $set: { grade: "A" } and the document already has grade: "A", the index is not touched. This is why modifiedCount can be 0 even when matchedCount is 1.
Example — index maintenance during an update:
Collection: students
Indexes: { _id: 1 }, { age: 1 }, { name: 1 }
Document before: { _id: 1, name: "Ravi", age: 22, grade: "A" }
Update: { $set: { grade: "A+" }, $inc: { age: 1 } }
Document after: { _id: 1, name: "Ravi", age: 23, grade: "A+" }
Index maintenance:
_id index: NO CHANGE (_id did not change, and _id is immutable)
age index: DELETE (22 → _id:1), INSERT (23 → _id:1) ← age changed
name index: NO CHANGE (name did not change) ← optimization!
grade: NO INDEX (grade is not indexed) ← no work needed
6. Replica Set Write Path
Like inserts, all updates go to the primary. Secondaries replicate by tailing the oplog.
+----------------+ +-------------------+ +-------------------+
| CLIENT | | PRIMARY (mongod) | | SECONDARY (mongod)|
+-------+--------+ +--------+----------+ +--------+----------+
| | |
| updateOne() | |
+──────────────────────▶ | |
| | |
| 4. Validate filter + update spec |
| 5. Take IX lock |
| 6. Find matching document(s) |
| 7. Apply update operators |
| Write to WT cache + journal |
| 8. Write oplog entry |
| op: "u", delta: {$set:{grade:"A+"}}|
| | |
| | oplog tailing |
| + ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ▶|
| | |
| | Replay the update:
| | find doc by _id
| | apply same operators
| | write to WT cache
| | |
| |◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─+
| | acknowledgment |
| | |
| 9. Write concern | |
| w:1 ── ack immediately | |
| w:majority ── wait | |
| | |
|◀───────────────────────────+ |
| 11. Response to client | |
| | |
Key points for updates:
- The oplog entry for an operator update (
$set,$inc) records the delta, not the full document. This makes replication efficient, especially for large documents where only a small field changed. - The oplog entry for a replacement (
replaceOne) records the full new document, because the entire document was replaced. - Secondaries apply the same operators to the same document (matched by
_id). This is called idempotent replication — applying the same oplog entry twice produces the same result (for most operators). - If a secondary receives an update for a document that does not exist (e.g., the document was deleted on the primary before the secondary could replicate the update), the secondary silently skips the operation.
7. updateOne vs updateMany vs replaceOne
| Aspect | updateOne() | updateMany() | replaceOne() |
|---|---|---|---|
| Documents modified | At most 1 | All matching documents | At most 1 |
| Update format | Update operators required ($set, $inc, etc.) | Update operators required | Full replacement document (no operators). The _id is preserved. |
| Oplog entry | Delta (operators) | One delta entry per document | Full new document |
| Index maintenance | Only changed fields | Only changed fields per document | All indexes may be updated because the entire document is replaced |
| Upsert support | Yes (upsert: true) | Yes (upsert: true) | Yes (upsert: true) |
Upsert behavior: When upsert: true is set and no document matches the filter, MongoDB performs an insert instead of an update. The new document is constructed from the filter and the update specification:
// No student named "Amit" exists yet
db.students.updateOne(
{ name: "Amit" },
{ $set: { age: 24, grade: "B" } },
{ upsert: true }
)
// MongoDB inserts:
// { _id: ObjectId("..."), name: "Amit", age: 24, grade: "B" }
// The filter fields (name) and the $set fields (age, grade) are merged.
Atomicity:
updateOne()is atomic at the document level. No other operation can see a partially updated document.updateMany()is not atomic across documents. Other operations may see some documents updated and others not yet updated. If you need multi-document atomicity, use a multi-document transaction (MongoDB 4.0+ for replica sets, 4.2+ for sharded clusters).
8. See It Yourself
Like insertOne(), there is no explain() for the write phase of an update. However, you can use explain() on the find phase (the filter), and you can observe the write behavior using these tools:
// 1. Run an update and see the response
var result = db.students.updateOne(
{ name: "Ravi" },
{ $set: { grade: "A+" }, $inc: { age: 1 } }
)
printjson(result)
// { acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedId: null }
// 2. Explain the FIND phase of the update (which indexes are used?)
db.students.explain("executionStats").updateOne(
{ name: "Ravi" },
{ $set: { grade: "A+" } }
)
// Look at winningPlan to see if IXSCAN or COLLSCAN is used for the filter
// 3. Check the oplog entry (replica set)
use local
db.oplog.rs.find({ ns: "myFirstDB.students", op: "u" })
.sort({ ts: -1 }).limit(1)
// You will see the delta: { $set: { grade: "A+" } }
// 4. Check current operations while running a large updateMany
// In another mongosh session:
db.currentOp({ "command.update": "students" })
// 5. Check write metrics
db.serverStatus().opcounters
// { insert: 1523, query: 4821, update: 345, ... }
// 6. Enable profiling to see slow updates
db.setProfilingLevel(2)
db.students.updateMany({ age: { $lt: 21 } }, { $set: { status: "junior" } })
db.system.profile.find({ op: "update" }).sort({ ts: -1 }).limit(1)
db.setProfilingLevel(0)
// 7. Observe matchedCount vs modifiedCount
db.students.updateOne(
{ name: "Ravi" },
{ $set: { grade: "A+" } } // already "A+" from step 1
)
// { matchedCount: 1, modifiedCount: 0 } ← value did not change!
// 8. Test upsert
db.students.updateOne(
{ name: "NewStudent" },
{ $set: { age: 19, grade: "C" } },
{ upsert: true }
)
// { matchedCount: 0, modifiedCount: 0, upsertedId: ObjectId("...") }
Key fields in the oplog entry for an update:
| Field | What It Tells You |
|---|---|
ts | Timestamp of the operation |
op | "u" for update |
ns | Namespace: myFirstDB.students |
o | The update specification (delta for operators, full doc for replacement) |
o2 | The filter used to find the document (typically just { _id: ... }) |
9. Oracle Terms Comparison
For DBAs coming from Oracle, here is a side-by-side mapping of every update step:
| MongoDB | Oracle |
|---|---|
Client sends the update command | Client sends the UPDATE SQL statement |
Authorization check (update action) | Privilege check (UPDATE on the table) |
| Parse filter + validate update operators | Parse (syntax + semantic check on SET and WHERE) |
| Query planner finds matching docs (IXSCAN / COLLSCAN) | Optimizer chooses access path for the WHERE clause (INDEX RANGE SCAN / FULL TABLE SCAN) |
| Intent-exclusive (IX) lock on collection | Row-level exclusive lock (TX) + table-level row exclusive (RX/SX) |
$set — modify specific fields | SET col = val — modify specific columns |
$inc — increment a field | SET col = col + 1 |
$unset — remove a field | SET col = NULL (closest equivalent; Oracle cannot drop a column per-row) |
replaceOne() — replace entire document | UPDATE ... SET all columns |
| Schema validation on the new document | Constraint check (NOT NULL, CHECK, FK) on the new values |
| Unique index check (exclude current doc) | Unique constraint / primary key check |
| In-place update if size is similar | In-place update within the data block |
| Document relocation if it grows too large | Row migration (forwarding pointer) |
| Index maintenance: delete old + insert new (only changed fields) | Index maintenance: delete old + insert new (only changed indexed columns) |
| Old version in B-tree (MVCC) | Old version in undo segment |
| Journal (WAL) for crash recovery | Redo log for crash recovery |
Oplog delta (op: "u") | Redo log record (physical block change) |
| Auto-commit (single-document atomic) | Explicit COMMIT required |
upsert: true | MERGE statement (INSERT when not matched, UPDATE when matched) |
matchedCount / modifiedCount | SQL%ROWCOUNT (rows affected) |
| Multi-document transaction (4.0+) | Standard transaction (BEGIN … COMMIT / ROLLBACK) |
explain() on the filter | EXPLAIN PLAN on the WHERE clause |