MongoDB Replace vs Update ($set): What Shows Up in the Oplog Might Surprise You
5 min read
If you’ve ever tailed MongoDB’s oplog or worked with Change Streams, you’ve probably run into a subtle but important difference between replace and update ($set) operations. The way MongoDB records these two in the oplog is fundamentally different — and it can bite you if you’re building anything downstream that depends on the full document.
Key takeaway: MongoDB oplog entries for replaceOne contain the full document, while updateOne with $set only records a diff — and that difference directly impacts downstream systems like CDC pipelines, change streams, and event-driven architectures.
Let’s walk through it.
The Setup
Say we have a users collection with this document:
{
"_id": "user_123",
"name": "Ravi",
"email": "ravi@example.com",
"age": 28,
"city": "Pune"
}
Now, let’s update the age field using two different approaches and see what ends up in the oplog.
Approach 1: Replace (replaceOne)
db.users.replaceOne(
{ _id: "user_123" },
{
_id: "user_123",
name: "Ravi",
email: "ravi@example.com",
age: 29,
city: "Pune"
}
);
What the oplog entry looks like:
{
"op": "u",
"ns": "mydb.users",
"o": {
"_id": "user_123",
"name": "Ravi",
"email": "ravi@example.com",
"age": 29,
"city": "Pune"
},
"o2": {
"_id": "user_123"
}
}
The o field contains the entire document. MongoDB treats this as “here’s the new version of the document, wholesale.” Anyone reading the oplog gets the full picture.
Approach 2: Update with $set (patch)
db.users.updateOne(
{ _id: "user_123" },
{ $set: { age: 29 } }
);
What the oplog entry looks like:
{
"op": "u",
"ns": "mydb.users",
"o": {
"$v": 2,
"diff": {
"u": {
"age": 29
}
}
},
"o2": {
"_id": "user_123"
}
}
Notice the difference? The o field only contains the diff — just the fields that changed. There’s no name, no email, no city. If you’re tailing the oplog and need the full document, you won’t get it directly, you’ll need an additional lookup or maintain state yourself.
Note: The diff-based oplog format (
$v: 2) was introduced in MongoDB 5.0+. Older versions log updates differently, using$setoperators directly in the oplog entry rather than a structured diff. If you’re running an older cluster, your oplog entries for updates will look different from what’s shown above.
Why This Matters
If you’re building any of the following, this distinction is critical:
- Change Data Capture (CDC) pipelines — tools like Debezium or custom oplog tailers that sync data to Elasticsearch, Kafka, or a data warehouse
- Event-driven architectures — where downstream services react to document changes
- Audit logging — where you need to capture the full state of a document after every mutation
With replaceOne, your oplog consumer gets the full document for free. With updateOne + $set, it only gets the delta.
Change Streams and fullDocument option
MongoDB’s Change Streams offer a built-in workaround. You can pass the fullDocument option:
const stream = db.users.watch(
[],
{ fullDocument: "updateLookup" }
);
stream.on("change", (change) => {
console.log(change.fullDocument); // full document after the update
});
When you set fullDocument: "updateLookup", MongoDB performs an additional read against the collection to fetch the current state of the document and attaches it to the change event.
But there’s a catch (actually, a few):
- Extra read load — every update event now triggers a secondary query. At high write throughput, this adds up.
- No point-in-time guarantee — the document might have been modified again between the original update and the lookup. So the
fullDocumentyou receive could be a newer version than what the update produced. In fast-moving collections, this can lead to subtle inconsistencies. - Document might be deleted — if the document was deleted before the lookup happens,
fullDocumentwill benull.
The Cost of replaceOne vs $set
Before you jump to “just use replaceOne everywhere,” it’s worth understanding the write-side cost.
replaceOne rewrites the entire document to disk, even if only one field changed. That means more disk I/O, more replication traffic between nodes, and potentially more index updates if the document shape triggers re-indexing. For large documents with frequent small updates, this adds up fast.
$set, on the other hand, is surgical. It touches only the changed fields, making it significantly more efficient for small, targeted mutations.
So choosing replaceOne for CDC convenience is a trade-off, not a free win. You’re paying for downstream simplicity with write-side overhead. Whether that’s worth it depends on your document sizes, write throughput, and how critical the full document is downstream.
So What Should You Do?
There’s no universal answer, but here’s a rough mental model:
| Scenario | Preferred Approach |
|---|---|
| You control the writer and need full documents downstream | Use replaceOne — the oplog gives you everything, but be aware of the write amplification cost |
| You’re doing surgical field-level updates and don’t need the full doc downstream | Use updateOne + $set — it’s more efficient |
| You need full documents but can’t change the write pattern | Use Change Streams with fullDocument: "updateLookup", but understand the trade-offs |
| You need strong consistency on the full document | Consider reading from the primary after the update, or design around eventual consistency |
One More Thing: fullDocumentBeforeChange
Starting with MongoDB 6.0, Change Streams also support fullDocumentBeforeChange, which gives you the document state before the change. This requires enabling changeStreamPreAndPostImages on the collection:
db.createCollection("users", {
changeStreamPreAndPostImages: { enabled: true }
});
Then:
const stream = db.users.watch(
[],
{
fullDocument: "updateLookup",
fullDocumentBeforeChange: "whenAvailable"
}
);
This is incredibly useful for audit trails and diffing, but comes with additional storage overhead since MongoDB now needs to retain pre-images.
Wrapping Up
The difference between replace and update in MongoDB goes deeper than just “one sends the whole doc, the other sends a patch.” It ripples out into your oplog, your change streams, your CDC pipelines, and ultimately your data architecture.
Next time you’re choosing between replaceOne and updateOne, think beyond the write itself. Think about who’s reading that oplog entry downstream.
Thanks for reading. Have a great day!