This content originally appeared on DEV Community and was authored by Franck Pachot
In MongoDB, when you update a field to the same value it already holds, the database optimizes the operation by skipping index writes if no indexed fields have changed and avoids rewriting the document when no fields differ. This behavior contrasts with SQL databases.
Such updates occur more frequently than one might expect. For instance, when an ORM updates all fields instead of just the modified ones to reduce the number of statements to parse, when an application saves all data from the UI—even if it originates from a previous read, or during periodic syncs from external systems that provide a complete snapshot rather than incremental changes.
SQL update to the same value
Before exposing this in MongoDB, here is an example on PostgreSQL. I create a simple table with one indexed column, the primary key, and one unindexed column:
create table test as
select
generate_series(1,1000) as key,
'hello world' as val
;
alter table test add primary key (key)
;
create index on test (val)
;
vacuum analyze test
;
I update the unindexed column to the same value (set val=val
) for one row (where key = 42
) in a loop (\watch
), and look at the number of WAL records generated (explain (analyze, wal)
):
explain (analyze, buffers, wal, costs off, summary off)
update test
set val=val
where key = 42
\watch
After multiple modifications to the same value, it still generates one WAL record for the change to the table row:
Update on test (actual time=0.042..0.042 rows=0.00 loops=1)
Buffers: shared hit=5
WAL: records=1 bytes=68
-> Index Scan using test_pkey on test (actual time=0.023..0.024 rows=1.00 loops=1)
Index Cond: (key = 42)
Index Searches: 1
Buffers: shared hit=3
PostgreSQL detects that it is the same value, and avoids updating the index entry. However, the table is updated to the same value. There are reasons for that: in an SQL database, DML (Data Manipulation Language) statements like UPDATE denote an intention to update. Even if the value is the same, the user can expect locks to be acquired, triggers to be raised, and the audit log to record it.
I’ve run the update multiple times to get the optimized scenario. The first update generated four WAL records: it inserted the new version (with the same values) into a new block, updated the previous version, and modified two index entries to reference this new block. Since the new tuple was appended to the table without any concurrent inserts or updates, the second update found free space within the same block. This allowed it to avoid generating additional WAL records for updating the physical location, utilizing a HOT update optimization. However, it did create one WAL record for the Heap Only Tuple and an additional record during the read phase to clean up the old index entry. The next runs find space in the same block (as the previous versions there can be cleaned up) and do not have to clean up old index entries, so they continue with a single WAL record generated.
MongoDB $set to the same value
In SQL, statements declare an intention, which affects storage even when updating to the same value, to record the intention (in triggers, audit, transaction manager). In contrast, MongoDB statements declare a state, representing the new version of a document to synchronize transient application objects with the persistent database. Moreover, it’s beneficial to use idempotent calls to a resilient database, allowing updates to the same value, as this practice is not uncommon and facilitates retrying in case of failures.
When MongoDB applies an update to a document, it compares the two versions (DiffApplier), not only to optimize for this case but also to record only the changes to save memory in the cache. During this comparison, it skips updating indexes for fields that didn’t change between the two versions, and even skips the update when the two versions are identical.
I’m writing this following a question on MongoDB forum. To answer accurately, I tested in a lab where I can log what is actually updated.
Logging writes in MongoDB
I start a lab that logs the write operations:
# Start MongoDB with db.setLogLevel(1, "write")
docker run --name mg -d -p 27017:27017 mongo \
--setParameter 'logComponentVerbosity={write:{verbosity:1}}' \
--replSet rs0
mongosh --eval "rs.initiate()"
# display logs nicely formatted (with jq)
docker logs -f mg | jq -c ' select(.c=="WRITE") | {
keysInserted: .attr.keysInserted,
keysDeleted: .attr.keysDeleted,
nMatched: .attr.nMatched,
nModified: .attr.nModified,
nUpserted: .attr.nUpserted,
planSummary: .attr.planSummary,
keysExamined: .attr.keysExamined,
docsExamined: .attr.docsExamined,
component: .c,
numYields: .attr.numYields,
locks: {
Global: .attr.locks.Global.acquireCount.w,
Database: .attr.locks.Database.acquireCount.w,
Collection: .attr.locks.Collection.acquireCount.w,
}
}' &
If you don’t have jq
installed you can simply docker logs -f
, but I wanted an output that fits well in this blog post.
I connect with mongosh
and create a collection with one document, one indexed field and one non-indexed field:
db.test.insertOne({ _id: 42, indexedField: "A", otherField: 101 });
db.test.createIndex({ indexedField: 1 });
I run an update that sets the same value on the indexed field:
db.test.updateOne(
{ _id: 42 },
{ $set: { indexedField: "A" }
})
{"keysInserted":null,"keysDeleted":null,"nMatched":1,"nModified":0,"nUpserted":0,"planSummary":"EXPRESS_IXSCAN { _id: 1 },EXPRESS_UPDATE","keysExamined":1,"docsExamined":1,"component":"WRITE","numYields":0,"locks":{"Global":1,"Database":1,"Collection":1}}
The read part is executed, with an index scan on the key (EXPRESS_IXSCAN) finding one key and one document, but there’s no document modified ("nModified":0
). "keysInserted"
and "keysDeleted"
, related to the index, are not even present in the log.
I run another update that sets the non-indexed field to a different value:
db.test.updateOne(
{ _id: 42 },
{ $set: { otherField: 102 }
})
{"keysInserted":0 ,"keysDeleted":0 ,"nMatched":1,"nModified":1,"nUpserted":0,"planSummary":"EXPRESS_IXSCAN { _id: 1 },EXPRESS_UPDATE","keysExamined":1,"docsExamined":1,"component":"WRITE","numYields":0,"locks":{"Global":1,"Database":1,"Collection":1}}
Here, the document was modified ("nModified":1
) but index key modification has been skipped ("keysInserted":0 ,"keysDeleted":0
) as it is not needed to find the document.
The write amplification seen in PostgreSQL when the new version must be written to a new location doesn’t happen here because in MongoDB the indexes reference a logical RecordId rather than the physical location like PostgreSQL’s CTID.
I update the indexed field to a new value:
db.test.updateOne(
{ _id: 42 },
{ $set: { indexedField: "B" }
})
{"keysInserted":1 ,"keysDeleted":1 ,"nMatched":1,"nModified":1,"nUpserted":0,"planSummary":"EXPRESS_IXSCAN { _id: 1 },EXPRESS_UPDATE","keysExamined":1,"docsExamined":1,"component":"WRITE","numYields":0,"locks":{"Global":1,"Database":1,"Collection":1}}
This must modify the document and the index entry. The index entry for the old value is deleted ("keysDeleted":1
) and the new one inserted ("keysInserted":1
).
Finally, I set back the document to the initial values, changing both fields:
db.test.updateOne(
{ _id: 42 },
{ $set: { indexedField: "A" , otherField: 101 }
})
{"keysInserted":1 ,"keysDeleted":1 ,"nMatched":1,"nModified":1,"nUpserted":0,"planSummary":"EXPRESS_IXSCAN { _id: 1 },EXPRESS_UPDATE","keysExamined":1,"docsExamined":1,"component":"WRITE","numYields":0,"locks":{"Global":1,"Database":1,"Collection":1}}
As at least one field is different, the document is modified ("nModified":1
) and as an indexed field has changed, the index entry is updated ("keysInserted":1 ,"keysDeleted":1
)
No write conflict in MongoDB
To validate that an update in MongoDB does not affect write consistency guarantees when it doesn’t change the document, I run an update to the same value within a transaction:
// start a transaction
const session = db.getMongo().startSession();
const TX = session.getDatabase(db.getName());
session.startTransaction();
// in the transaction, update the document to the same value
TX.test.updateOne(
{ _id: 42 },
{ $set: { indexedField: "A" , otherField: 101 }
})
This doesn’t modify the document as it is the same values:
{"keysInserted":null,"keysDeleted":null,"nMatched":1,"nModified":0,"nUpserted":0,"planSummary":"EXPRESS_IXSCAN { _id: 1 },EXPRESS_UPDATE","keysExamined":1,"docsExamined":1,"component":"WRITE","numYields":0,"locks":{"Global":1,"Database":1,"Collection":1}}
While the transaction is active, I update to a new value in another session:
// concurrently, update the document to a different value
db.test.updateOne(
{ _id: 42 },
{ $set: { indexedField: "A" , otherField: 102 }
})
This updates the document:
{"keysInserted":0,"keysDeleted":0,"nMatched":1,"nModified":1,"nUpserted":0,"planSummary":"EXPRESS_IXSCAN { _id: 1 },EXPRESS_UPDATE","keysExamined":1,"docsExamined":1,"component":"WRITE","numYields":0,"locks":{"Global":2,"Database":2,"Collection":2}}
That proves that the transaction didn’t record any write intention as there is no write conflict. It can commit:
// commit
session.commitTransaction();
If you run the same test but with different values, you will see the transparent retries, thanks to the automatic backoff loop, until the transaction times out (1 minute) and then update will be able to complete – the number of attempts being visible in the number of lightweight locks:
{"keysInserted":null,"keysDeleted":null,"nMatched":null,"nModified":null,"nUpserted":null,"planSummary":null,"keysExamined":null,"docsExamined":null,"component":"WRITE","numYields":null,"locks":{"Global":null,"Database":null,"Collection":null}}
...
{"keysInserted":null,"keysDeleted":null,"nMatched":null,"nModified":null,"nUpserted":null,"planSummary":null,"keysExamined":null,"docsExamined":null,"component":"WRITE","numYields":null,"locks":{"Global":null,"Database":null,"Collection":null}}
{"keysInserted":0,"keysDeleted":0,"nMatched":1,"nModified":1,"nUpserted":0,"planSummary":"EXPRESS_IXSCAN { _id: 1 },EXPRESS_UPDATE","keysExamined":534,"docsExamined":534,"component":"WRITE","numYields":533,"locks":{"Global":535,"Database":535,"Collection":535}}
Conclusion
I compare PostgreSQL and MongoDB as they are the most popular representatives of relational and document databases, respectively. Beyond performance, understanding the behavior is critical.
In PostgreSQL, an UPDATE statement indicates an intention to perform an operation, and the database executes it even if the stored value remains unchanged. This ensures SQL developers’ expectations are met: locks are acquired, triggers activate, and changes are logged. While index modification can be skipped, a new version of the row is still recorded to fulfill the command.
In contrast, MongoDB operations specify the desired final document state rather than an explicit action. When updating, MongoDB computes the new document version and, if unchanged, skips rewriting it and avoids unnecessary index writes. This approach aligns well with idempotent, retry-friendly patterns and reduces write amplification when no actual change occurs.
This content originally appeared on DEV Community and was authored by Franck Pachot