#Atomicity at the document level
Every write to a single document is atomic in MongoDB. One updateOne can change ten fields, push into an array and edit an embedded sub-document, and other clients see either the document before the whole update or after it, never half of it. This has been true since the first version, long before multi-document transactions existed, and it's why MongoDB's schema advice says "embed what you change together": if an order's items, totals and status live in one document, a single update keeps them consistent with no transaction at all.
A useful mental model: a document is a sealed envelope. The server opens it, makes all your changes, and seals it again before anyone else can look. Two envelopes are two separate seals, though: an updateMany that touches 50 documents makes 50 separate atomic writes, and a failure on the 30th leaves the first 29 changed.
The main example: add an item to order 2 and keep its itemCount, total and updatedAt in step, in one statement. The data is the site's sample shop (customers, products, orders with embedded items) loaded into the ch22 database on a three-member replica set.
db.orders.updateOne(
{ _id: 2 },
{
$push: { items: { productId: 7, qty: 1, price: 29 } },
$inc: { itemCount: 1, total: 29 },
$set: { updatedAt: new Date("2026-10-16T12:00:00Z") }
}
)
db.orders.findOne({ _id: 2 }){
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{
_id: 2,
customerId: 1,
status: 'paid',
itemCount: 3,
total: 117,
items: [
{ productId: 5, qty: 1, price: 39 },
{ productId: 6, qty: 1, price: 49 },
{ productId: 7, qty: 1, price: 29 }
],
updatedAt: ISODate('2026-10-16T12:00:00.000Z')
}- The filter
{ _id: 2 }finds exactly one document: Rohit's paid order with two books,itemCount: 2andtotal: 88. - Three update operators run against that one document:
$pushappends the charger toitems,$incadds1toitemCount(2 → 3) and29tototal(88 → 117), and$setwritesupdatedAt. - The server applies all three while holding the document's write intent, then commits the new version as one unit. A reader running at the same moment gets either the old document (2 items, 88) or the new one (3 items, 117), never "3 items but total still 88".
matchedCount: 1, modifiedCount: 1confirms one document changed. If the server crashed half-way, recovery would bring back the old version, not a mix.- This is the whole idea: when related values live in one document, one ordinary update keeps them consistent. No session, no transaction, no extra round trips.
updateMany is many single-document writes in a row. If one fails, the ones before it stay changed. Here a unique index on email makes the second Toronto customer's update fail:
db.customers.createIndex({ email: 1 }, { unique: true })
db.customers.updateMany({ city: "Toronto" }, { $set: { email: "toronto@example.com" } })
db.customers.find({ city: "Toronto" }, { name: 1, email: 1 })email_1
Uncaught MongoServerError: Plan executor error during update :: caused by :: E11000 duplicate key error collection: ch22.customers index: email_1 dup key: { email: "toronto@example.com" }
[
{ _id: 1, name: 'Rohit', email: 'toronto@example.com' },
{ _id: 5, name: 'Noah', email: 'noah@example.com' }
]- Two customers match
city: "Toronto": Rohit (_id: 1) and Noah (_id: 5). - The first document is updated and committed: Rohit's email becomes
toronto@example.com. - The second write would give Noah the same email, which the unique index rejects with
E11000, and the command stops with an error. - Rohit's change is not undone. The command failed, yet the collection is half-updated. In SQL an
UPDATEstatement is atomic as a whole; in MongoDB only each document is. - If you need "all of them or none", wrap the
updateManyin a transaction (next topic) or redesign so the data lives in one document.
The most common concurrency bug in MongoDB code isn't about transactions at all: the app reads a document, decides in code, then writes. Between the read and the write, other clients do the same. Eight shells try to buy the last five MacBook Airs at the same moment, first the broken way:
// The broken version: read, decide in the app, then write
const p = db.products.findOne({ _id: 2 });
sleep(200); // the app "thinks" (any real work between read and write)
if (p.stock >= 1) {
db.products.updateOne({ _id: 2 }, { $set: { stock: p.stock - 1 } });
print("sold, stock was " + p.stock);
} else print("sold out");shell 2: sold, stock was 5
shell 6: sold, stock was 5
shell 3: sold, stock was 5
shell 5: sold, stock was 5
shell 1: sold, stock was 5
shell 4: sold, stock was 5
shell 8: sold, stock was 5
shell 7: sold, stock was 5
{ _id: 2, name: 'MacBook Air', stock: 4 }- All eight shells run
findOnewithin a few milliseconds of each other, so all eight readstock: 5. - Each one decides "5 ≥ 1, I can sell" and writes
$set: { stock: 4 }, a value computed from its own stale read. - Every write is atomic, but each one simply overwrites the last: eight sales were promised and the stock went
5 → 4. That's a lost update. - The single-document atomicity guarantee didn't help because the decision happened outside the database.
The fix is to put the condition inside the update, so the check and the change are one atomic operation:
// "Buy one MacBook Air", run by 8 shells at the same moment
const r = db.products.updateOne(
{ _id: 2, stock: { $gte: 1 } }, // only if there is one left
{ $inc: { stock: -1 } }
);
print(r.modifiedCount === 1 ? "sold" : "sold out");shell 3: sold
shell 1: sold
shell 4: sold
shell 6: sold
shell 5: sold out
shell 2: sold
shell 7: sold out
shell 8: sold out
{ _id: 2, name: 'MacBook Air', stock: 0 }- Each shell sends one
updateOnewhose filter says "product 2 and stock is at least 1". - The server evaluates the filter and applies
$inc: -1atomically per document. Two updates to the same document are serialised, so the fifth sale seesstock: 1and takes it to0. - The sixth, seventh and eighth updates find no document matching
stock: { $gte: 1 }, somodifiedCountis0and they print "sold out". - Exactly five sales, stock exactly
0. This compare-and-set pattern (filter on the expected state, change it atomically) is how most MongoDB apps avoid transactions altogether. It's the same idea as optimistic locking in SQL, see Optimistic vs pessimistic locking.
When you need the result of the change (the new stock, the next counter value, the claimed job), findOneAndUpdate does the match, the update and the read back as one atomic step:
db.products.findOneAndUpdate(
{ _id: 3, stock: { $gte: 2 } },
{ $inc: { stock: -2 } },
{ returnDocument: "after", projection: { name: 1, stock: 1 } }
)
db.products.findOneAndUpdate(
{ _id: 4, stock: { $gte: 1 } },
{ $inc: { stock: -1 } },
{ returnDocument: "after", projection: { name: 1, stock: 1 } }
){ _id: 3, name: 'Pixel 10', stock: 18 }
null- The Pixel 10 has
stock: 20, so the first call matches, decrements by 2 and, because ofreturnDocument: "after", returns the document as it is after the update:18. - The iPhone 17 has
stock: 0, so the filterstock: { $gte: 1 }matches nothing and the call returnsnull. Nothing was changed. - No other client can slip in between the match and the returned value, which makes this the building block for job queues ("claim the next pending job") and counters.
- With
returnDocument: "before"(the default) you'd get the old version instead.
Product 2 has stock: 5. Eight clients run this at the same time. What's the final stock, and how many clients think they sold one?
const p = db.products.findOne({ _id: 2 });
sleep(200); // the app does some work
if (p.stock > 0) db.products.updateOne({ _id: 2 }, { $inc: { stock: -1 } });Show answer
The final stock is -3, and all eight think they sold one.
- All eight
findOnecalls run before any update lands, so every client readsstock: 5and passes thep.stock > 0check. - This time the write is
$inc: -1, not$set: p.stock - 1, so there's no lost update: each decrement is applied atomically to the current value. - Eight decrements of 5 gives
5 → -3. The$incfixed the lost-update bug but not the oversell, because the check still happened in the app on a stale value. - The fix is to move the check into the filter:
updateOne({ _id: 2, stock: { $gt: 0 } }, { $inc: { stock: -1 } }), which stops at exactly0.