MongoDB updateOne: $set, $inc and Upserts Without Surprises

MongoDB updateOne changes one document, and its result object tells you whether anything changed. The two numbers inside it, matchedCount and modifiedCount, are easy to misread. This post tests them, along with $set, $inc, upserts and the one mistake that wipes a document.

Gouache painting of a wall of small wooden card drawers with one vermilion drawer pulled open and a fresh card among older ones.

A Small Shop to Test On

Every result below comes from MongoDB 9.0.2 with mongosh 2.13.0. The collection holds four products from a vegetarian shop. Each has a name, a category, a price and a stock count. If the vocabulary is new to you, my post on SQL terms vs MongoDB terms explains it.

use updateDemo
db.products.insertMany([
  { _id: 1, name: 'Oat Milk',    category: 'drinks',  price: 4, stock: 20 },
  { _id: 2, name: 'Lentil Soup', category: 'soup',    price: 6, stock: 15 },
  { _id: 3, name: 'Tofu Block',  category: 'protein', price: 3, stock: 40 },
  { _id: 4, name: 'Mango Juice', category: 'drinks',  price: 5, stock: 12 }
])

Three Update Methods

MongoDB has three main ways to change a document. They differ in how many documents they touch and in what they do to each one.

MethodDocuments touchedWhat it does
updateOneThe first matchApplies update operators such as $set
updateManyEvery matchApplies update operators such as $set
replaceOneThe first matchSwaps the whole document, keeping only the _id

A filter on _id matches exactly one document, so updateOne is exact there. A filter on a name or a category can match many, and updateOne quietly picks one. Choose the method before you write the filter, not after.

$set and the Result Object

$set changes a field, or adds it when the document doesn’t have it. Here MongoDB updateOne raises the price of Oat Milk from 4 to 5. The command returns a result object. matchedCount is how many documents the filter found, and modifiedCount is how many changed.

db.products.updateOne({ name: 'Oat Milk' }, { $set: { price: 5 } })
// { acknowledged: true, insertedId: null, matchedCount: 1,
//   modifiedCount: 1, upsertedCount: 0 }

Now run the same command again. The filter still finds one document, so matchedCount stays 1. The price is already 5, so there is nothing to change, and modifiedCount drops to 0. It’s not an error. It’s the one case where “matched” and “modified” disagree.

db.products.updateOne({ name: 'Oat Milk' }, { $set: { price: 5 } })
// { acknowledged: true, insertedId: null, matchedCount: 1,
//   modifiedCount: 0, upsertedCount: 0 }
db.products.updateOne({ name: 'Rice Cakes' }, { $set: { price: 2 } })
// { acknowledged: true, insertedId: null, matchedCount: 0,
//   modifiedCount: 0, upsertedCount: 0 }

The second command shows a filter that finds nothing. Both counts are 0, and MongoDB reports no error. A script that needs a change should read matchedCount first. When it is 0, check the filter for a typo.

$inc Adds Without Reading

$inc adds a number to a field, and a negative number subtracts. It’s safer than reading the stock into your code and writing it back. The server does the arithmetic in one atomic step. If the field doesn’t exist, $inc creates it with your number as the value.

db.products.updateOne({ name: 'Oat Milk' }, { $inc: { stock: -3 } })
db.products.findOne({ name: 'Oat Milk' })
// { _id: 1, name: 'Oat Milk', category: 'drinks', price: 5, stock: 17 }
db.products.updateOne({ name: 'Tofu Block' }, { $inc: { sold: 2 } })
db.products.findOne({ name: 'Tofu Block' })
// { _id: 3, name: 'Tofu Block', category: 'protein', price: 3, stock: 40, sold: 2 }

One Document or Many

Two products are in the drinks category. MongoDB updateOne with that filter still changes only one of them, and matchedCount says 1. The other drink is untouched. If you wanted both, you needed updateMany.

db.products.updateOne({ category: 'drinks' }, { $set: { onSale: true } })
// { acknowledged: true, insertedId: null, matchedCount: 1,
//   modifiedCount: 1, upsertedCount: 0 }
db.products.updateMany({ category: 'drinks' }, { $set: { onSale: true }, $inc: { stock: 10 } })
// { acknowledged: true, insertedId: null, matchedCount: 2,
//   modifiedCount: 2, upsertedCount: 0 }

Both drinks count as modified, because the stock changed in both. Oat Milk already had onSale set, but one changed field is enough. A document counts as unmodified only when the update leaves it exactly as it was.

Upsert: Update or Insert

An upsert is an update that inserts a document when nothing matches. You switch it on with { upsert: true } as the third argument. MongoDB builds the new document from the equality fields in the filter plus the changes in your update. A $inc on a missing field starts from zero.

db.products.updateOne({ _id: 5, name: 'Almond Milk' }, { $set: { price: 6 }, $inc: { stock: 8 } }, { upsert: true })
// { acknowledged: true, insertedId: 5, matchedCount: 0,
//   modifiedCount: 0, upsertedCount: 1 }
db.products.findOne({ _id: 5 })
// { _id: 5, name: 'Almond Milk', price: 6, stock: 8 }

The result has matchedCount 0 and upsertedCount 1. The new _id appears in insertedId. Drivers such as the Node.js driver call that field upsertedId, so look for either name. When the filter has no _id, MongoDB generates an ObjectId for the new document.

Now run the same upsert again. This time the filter finds the document, so nothing is inserted. The $inc runs a second time and the stock becomes 16. An upsert with $inc is safe to run but not safe to repeat.

db.products.updateOne({ _id: 5, name: 'Almond Milk' }, { $set: { price: 6 }, $inc: { stock: 8 } }, { upsert: true })
// { acknowledged: true, insertedId: null, matchedCount: 1,
//   modifiedCount: 1, upsertedCount: 0 }
db.products.findOne({ _id: 5 })
// { _id: 5, name: 'Almond Milk', price: 6, stock: 16 }

The Mistake That Wipes a Document

Say you want to change the price of Lentil Soup to 7. You pass the new price as a plain document, without an operator. MongoDB updateOne refuses it and explains why.

db.products.updateOne({ name: 'Lentil Soup' }, { price: 7 })
// Update document requires atomic operators

That guard exists for a reason. The same plain document given to replaceOne is accepted without a word, because replacing is its job. The document is swapped for { price: 7 }, and only the _id survives.

db.products.replaceOne({ name: 'Lentil Soup' }, { price: 7 })
// { acknowledged: true, insertedId: null, matchedCount: 1,
//   modifiedCount: 1, upsertedCount: 0 }
db.products.findOne({ _id: 2 })
// { _id: 2, price: 7 }

The name, the category and the stock are gone, and MongoDB reports success. The result looks the same as a healthy update: matched 1, modified 1. Nothing in it warns you. The only protection is to read the document afterward, or to choose the right command up front.

You could say replaceOne is the cleaner command, since it states the whole document. Fair point. When you want an exact new shape, it does exactly that. But you must send every field you want to keep. Here the full document keeps everything and raises the Tofu Block price from 3 to 4.

db.products.replaceOne({ _id: 3 }, { name: 'Tofu Block', category: 'protein', price: 4, stock: 40 })
db.products.findOne({ _id: 3 })
// { _id: 3, name: 'Tofu Block', category: 'protein', price: 4, stock: 40 }

For a one-field change, $set is shorter and cannot lose a field you forgot. Use replaceOne when the new document is the point.

The Same Ideas in SQL

MongoDBSQL Server
updateOne({ name: ‘Oat Milk’ }, { $set: { price: 5 } })UPDATE TOP (1) Products SET Price = 5 WHERE Name = ‘Oat Milk’
updateMany({ category: ‘drinks’ }, { $inc: { stock: 10 } })UPDATE Products SET Stock = Stock + 10 WHERE Category = ‘drinks’
updateOne(filter, update, { upsert: true })MERGE, or an UPDATE followed by an INSERT when no row was changed
replaceOne(filter, newDocument)An UPDATE that sets every column

The last row is the trap. In SQL, a missing column in your UPDATE leaves that column alone. In MongoDB, a missing field in a replacement document deletes it.

A Simple Rule

Use an update operator for every change, and reach for replaceOne only on purpose. Before a MongoDB updateOne on real data, run countDocuments with the same filter. Afterward, read both numbers in the result: matchedCount tells you about the filter, and modifiedCount tells you about the change. For an upsert, remember that a second run can add again.

The same habit helps outside tests. Keep updates small and name each field. When a script updates in a loop, log the two counts for every call. A line with matchedCount 1 and modifiedCount 0 is fine. A line with matchedCount 0 deserves a look.

When you finish testing, remove the example database.

use updateDemo
db.dropDatabase()

A safe update is not the right command, it is naming only the fields you mean to change.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

Database, MongoDB, NoSQL, SQL Scripts
Previous Post
MongoDB find: Filters, Projections and Sorting for SQL People
Next Post
MongoDB deleteMany: The Empty Filter That Deletes Everything

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.