MongoDB: How UPDATE Works

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:

MongoDBOracle
// Update one document (partial)
db.students.updateOne(
  { name: "Ravi" },
  { $set: { grade: "A+" },
    $inc: { age: 1 } }
)

// Update many documents
db.students.updateMany(
  { age: { $lt: 21 } },
  { $set: { status: "junior" } }
)

// Replace one document entirely
db.students.replaceOne(
  { name: "Ravi" },
  { name: "Ravi", age: 23,
    grade: "A+", major: "CS" }
)
-- Update one row (partial)
UPDATE students
SET grade = 'A+', age = age + 1
WHERE name = 'Ravi'
AND ROWNUM = 1;

-- Update many rows
UPDATE students
SET status = 'junior'
WHERE age < 21;

-- Replace is the same as update
-- all columns in Oracle
UPDATE students
SET age = 23, grade = 'A+',
    major = 'CS'
WHERE name = 'Ravi'
AND ROWNUM = 1;

COMMIT;

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 $greaterthan produce 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 because a and a.b conflict.
    • The _id field 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).

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 a sort option. 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:

  1. Read the current document from the WiredTiger cache (or from disk if the page is not cached).
  2. Apply the update operators to produce the new version of the document. For example, $set: { grade: "A+" } changes only the grade field; all other fields remain untouched. $inc: { age: 1 } adds 1 to the current age value.
  3. 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).
  4. Write the updated document to the collection B-tree in the WiredTiger cache.
  5. 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).
  6. 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, modifiedCount is 0 even though matchedCount is 1.
  • upsertedId: if upsert: true was set and no document matched, this contains the _id of the newly inserted document. Otherwise, it is null.

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 similarIn-place update within the data block
Document relocation if it grows too largeRow migration (forwarding pointer) or row chaining
Journal records logical operationsRedo log records physical block changes
Old versions reclaimed during eviction / checkpointUndo 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:

OperatorWhat It DoesIndex Impact
$setSets 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.
$unsetRemoves a field from the document.If the field is indexed, the old index entry is deleted. No new entry is inserted.
$incIncrements a numeric field by the given amount.Same as $set — old entry deleted, new entry inserted if the field is indexed.
$renameRenames a field.If the old or new name is indexed, both indexes are updated. Cannot rename _id.
$pushAppends a value to an array field.If the array field has a multikey index, new index entries are added for the new array elements.
$pullRemoves matching values from an array field.If the array field has a multikey index, index entries for the removed elements are deleted.
$addToSetAdds 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 / $maxUpdates the field only if the new value is less than / greater than the current value.Index updated only if the field value actually changes.
$currentDateSets 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

AspectupdateOne()updateMany()replaceOne()
Documents modifiedAt most 1All matching documentsAt most 1
Update formatUpdate operators required ($set, $inc, etc.)Update operators requiredFull replacement document (no operators). The _id is preserved.
Oplog entryDelta (operators)One delta entry per documentFull new document
Index maintenanceOnly changed fieldsOnly changed fields per documentAll indexes may be updated because the entire document is replaced
Upsert supportYes (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:

FieldWhat It Tells You
tsTimestamp of the operation
op"u" for update
nsNamespace: myFirstDB.students
oThe update specification (delta for operators, full doc for replacement)
o2The 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:

MongoDBOracle
Client sends the update commandClient sends the UPDATE SQL statement
Authorization check (update action)Privilege check (UPDATE on the table)
Parse filter + validate update operatorsParse (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 collectionRow-level exclusive lock (TX) + table-level row exclusive (RX/SX)
$set — modify specific fieldsSET col = val — modify specific columns
$inc — increment a fieldSET col = col + 1
$unset — remove a fieldSET col = NULL (closest equivalent; Oracle cannot drop a column per-row)
replaceOne() — replace entire documentUPDATE ... SET all columns
Schema validation on the new documentConstraint 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 similarIn-place update within the data block
Document relocation if it grows too largeRow 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 recoveryRedo log for crash recovery
Oplog delta (op: "u")Redo log record (physical block change)
Auto-commit (single-document atomic)Explicit COMMIT required
upsert: trueMERGE statement (INSERT when not matched, UPDATE when matched)
matchedCount / modifiedCountSQL%ROWCOUNT (rows affected)
Multi-document transaction (4.0+)Standard transaction (BEGIN … COMMIT / ROLLBACK)
explain() on the filterEXPLAIN PLAN on the WHERE clause
Caution: Your use of any information or materials on this website is entirely at your own risk. It is provided for educational purposes only. It has been tested internally, however, we do not guarantee that it will work for you. Ensure that you run it in your test environment before using.
Thank you
Rajasekhar Amudala