Ch. 12 · MongoDB

MongoDB Single-Document Atomicity and Updates

Why one MongoDB update is all-or-nothing for a document, how conditional updates stop lost writes, and where single-document atomicity ends.

~5 min readintermediateupdated Oct 7, 2026

MongoDB guarantees that a write to a single document is atomic. Either the whole update is applied or none of it is, and no reader ever sees the document half-changed. That guarantee covers everything inside the document: nested fields, arrays, arrays of subdocuments. It’s the reason so much MongoDB data modelling advice says “put things that change together in one document”.

What the guarantee doesn’t do is protect logic that lives in your application. Most concurrency bugs in MongoDB code come from reading a document, deciding something in JavaScript or Java, and writing back. This guide is about closing that gap.

The examples are mongosh for MongoDB 7 and 8. They weren’t run against a live server while writing, so treat outputs as illustrative.

What “atomic” covers

Take an order with line items and a running total:

db.orders.insertOne({
  _id: 1001,
  status: "open",
  items: [{ sku: "A1", qty: 1, price: 30 }],
  total: 30,
  version: 1
})
JavaScript

This update adds an item, bumps the total and increments a version in one operation:

db.orders.updateOne(
  { _id: 1001 },
  {
    $push: { items: { sku: "B7", qty: 2, price: 10 } },
    $inc: { total: 20, version: 1 }
  }
)
JavaScript

No other reader or writer can see the order with the new item but the old total. If the server crashed midway, the document would not end up half-updated. That’s single-document atomicity, and it needs no transaction.

updateMany is different: each matched document is updated atomically, but the operation as a whole isn’t. If you update 10,000 documents and the primary steps down after 6,000, the first 6,000 stay changed. Readers can also see some documents updated and others not while it runs. Interviewers like asking about this because it’s easy to assume “one command, one atomic unit”.

The lost update problem

Here’s the bug pattern. Two requests try to reserve the last seat on a flight at the same time:

// Request A and Request B both run this
const flight = db.flights.findOne({ _id: "LH400" })   // seatsLeft: 1
if (flight.seatsLeft > 0) {
  db.flights.updateOne({ _id: "LH400" }, { $set: { seatsLeft: flight.seatsLeft - 1 } })
  // ...create booking
}
JavaScript

Both reads see seatsLeft: 1. Both pass the check. Both write seatsLeft: 0. Two bookings exist for one seat, and the counter looks perfectly fine. Each individual write was atomic; the race happened in the gap between read and write.

The fix is to move the check into the update filter, so the condition and the change happen in one atomic step:

const res = db.flights.updateOne(
  { _id: "LH400", seatsLeft: { $gt: 0 } },
  { $inc: { seatsLeft: -1 } }
)
// winner:  { acknowledged: true, matchedCount: 1, modifiedCount: 1 }
// loser:   { acknowledged: true, matchedCount: 0, modifiedCount: 0 }
JavaScript

Only one request can match the filter while seatsLeft is 1. The second request’s filter finds no document, so nothing changes. Your code must look at matchedCount (or modifiedCount) and treat 0 as “sold out”. Ignoring the result is the second half of this bug: the update didn’t fail with an error, it just didn’t match.

Notice also $inc instead of $set with a computed value. Update operators like $inc, $push, $addToSet, $pull and $min/$max compute the new value on the server from the current value, so they never write back a stale number.

Getting the document back: findOneAndUpdate

Often you need the result, for example to show the remaining seats or to claim a job from a queue. findOneAndUpdate does the match, update and return in one atomic step:

db.jobs.findOneAndUpdate(
  { status: "queued" },
  { $set: { status: "running", worker: "w-3", startedAt: new Date() } },
  { sort: { priority: -1, _id: 1 }, returnDocument: "after" }
)
JavaScript

Ten workers can run this at once and each job is claimed by exactly one of them. A worker that finds nothing gets null. This pattern is the standard answer to “how would you build a simple job queue on MongoDB”.

Optimistic concurrency with a version field

Sometimes the change can’t be expressed with operators, say a user edits a whole profile form and you replace many fields. Add a version number and require it in the filter:

const res = db.profiles.updateOne(
  { _id: userId, version: 7 },
  { $set: { bio: newBio, location: newLocation }, $inc: { version: 1 } }
)
if (res.matchedCount === 0) {
  // someone else saved first: reload and show a conflict
}
JavaScript

If another save already moved the version to 8, this update matches nothing and you know to reload rather than overwrite their changes. That’s optimistic locking, and it costs nothing more than an extra field.

Upserts and the duplicate-key race

updateOne(filter, update, { upsert: true }) inserts when nothing matches. Two concurrent upserts with the same filter can both find no match and both try to insert. If a unique index covers the filter field, one insert succeeds and the other fails with E11000 duplicate key error. If there’s no unique index, you can end up with two documents. So: put a unique index on whatever upserts match on, and retry once on duplicate key, because the retry will now find the document and update it. Since 4.2 the server retries some of these upsert conflicts internally when the filter is an equality match on the unique key, but you shouldn’t rely on that without the index.

Where atomicity stops

Single-document atomicity stops at the document boundary. Moving money between two account documents, decrementing stock in inventory while inserting into orders, or updating a user and their separate settings document are all multi-document changes. For those you have three options:

  • Remodel so the data that must change together lives in one document (often the best answer).
  • Use a multi-document transaction (available on replica sets and sharded clusters).
  • Design an idempotent sequence of single-document steps with a status field you can resume or compensate from.

Documents also have a 16 MB limit, so “embed everything to get atomicity” fails for unbounded growth like a chat room’s messages.

Interview practice

You run a coupon system. Each coupon document has { code, maxUses, uses }. The current redeem code reads the coupon, checks uses < maxUses, then does $inc: { uses: 1 }. In production, a coupon with maxUses: 100 ended with uses: 104. Explain why and fix it without a transaction.

Answer

Each $inc was atomic, so the counter itself is correct: there really were 104 increments. The problem is the check. It ran in application code on a value read earlier. Under load, several requests read uses: 99, all passed 99 < 100, and all incremented. The read and the write weren’t one atomic step.

The fix puts the condition in the filter. Since the limit lives in the same document, use $expr to compare two fields:

const res = db.coupons.updateOne(
  { code: "FALL25", $expr: { $lt: ["$uses", "$maxUses"] } },
  { $inc: { uses: 1 } }
)
if (res.matchedCount === 0) throw new Error("coupon exhausted or unknown")
JavaScript

Now the server evaluates uses < maxUses and increments in one operation on that document, so the counter can’t pass 100. I’d mention that if redeeming also has to write a redemptions record, either embed recent redemptions in the coupon (if bounded) or accept a two-step flow where the coupon increment is the gate and the redemption insert is idempotent, keyed by order id. A transaction is the fallback, not the first tool.

Further reading

More in MongoDB

read ✓MongoDB · mid

MongoDB Transactions and Data Modeling

When a MongoDB multi-document transaction is the right tool, what it costs, how to retry it correctly, and when a better model removes the need.

~5 min readread →
read ✓MongoDB · mid

MongoDB Bulk Writes

Batch a nightly upsert job with bulkWrite, pick ordered or unordered mode, and read writeErrors so partial failures don't get lost or retried.

~6 min readread →
read ✓MongoDB · mid

MongoDB Capped Collections

Size a capped collection for a recent-errors feed, follow it with a tailable cursor, and see why TTL indexes suit time-based retention better.

~6 min readread →
esc