Atomicity at the Document Level

Every write to a single document in MongoDB happens as one atomic operation: no other client can ever observe that document half-updated, and a crash in the middle of a write cannot leave it partially changed. This document-level atomicity is a guarantee MongoDB gives you for free, long before you reach for a multi-document transaction. Understanding exactly what it promises — and what it does not — is essential, because most real-world race conditions in MongoDB applications come from assuming atomicity that was never actually there.

Overview: What “Atomic” Means in MongoDB

In a relational database, a single logical update often touches multiple rows across multiple tables, so you reach for a transaction almost by default. In MongoDB, related data is usually embedded inside one BSON document — an order with its line items, a user profile with its settings — so a huge share of “transactional” updates in SQL become a single-document write in MongoDB, which is atomic without any special ceremony.

The unit of atomicity is the whole document, no matter how large or deeply nested it is. If an update modifies five fields, pushes two elements into a nested array, and removes one subdocument, all of those changes commit together as a single storage-engine transaction against that one document. A concurrent reader either sees the document exactly as it was before the write, or exactly as it is after — never a mixture of old and new values. This is why MongoDB’s update operators ($set, $inc, $push, $addToSet, $pull, and friends) exist: they let you describe a modification to apply in place on the server, rather than forcing you to read the document into your application, change it, and write the whole thing back.

That distinction matters more than it looks. A read-modify-write cycle (fetch a document, change a field in your application code, then save it) is two separate round trips to the server with a gap in between where another client can sneak in a conflicting write. An atomic operator like $inc is a single round trip: MongoDB reads the current value and applies the delta as one indivisible step on the server, so there is no gap for another operation to race into. findOneAndUpdate() takes this further by atomically finding a document, applying an update, and returning either the old or new version of the document, all as one operation — useful whenever your next decision depends on the value you just wrote.

It’s also important to be precise about the boundary of this guarantee. Atomicity in MongoDB applies per document, not per command. If you call updateMany() and it matches five documents, each of those five documents is updated atomically on its own, but the batch as a whole is not one atomic unit — MongoDB updates them one at a time, and a concurrent reader can legitimately see three documents already updated and two not yet touched while the command is still running. Reaching across multiple documents (or multiple collections) for true all-or-nothing behavior is exactly what multi-document transactions, covered elsewhere in this section, are for.

Syntax

Most document-level atomicity is expressed through update operators inside updateOne(), updateMany(), or the atomic read-and-modify command findOneAndUpdate():

db.collection.findOneAndUpdate(
  filterDocument,
  updateDocument,
  {
    returnDocument: "after", // or "before"
    upsert: false,
    sort: sortDocument
  }
);
  • filterDocument — the query used to locate the single matching document, e.g. { _id: "acct-77" }.
  • updateDocument — one or more update operators describing the in-place change, e.g. { $inc: { balance: -50 } }.
  • returnDocument — whether to return the document as it looked before or after the update is applied; defaults to "before".
  • upsert — if true, inserts a new document when no document matches the filter.
  • sort — when the filter could match more than one document, picks which single document to update.

The most common atomic update operators are:

Operator Effect Example
$set Sets one or more field values { $set: { status: "shipped" } }
$unset Removes a field entirely { $unset: { tempFlag: "" } }
$inc Atomically increments or decrements a number { $inc: { views: 1 } }
$mul Atomically multiplies a number { $mul: { price: 1.1 } }
$push Appends a value to an array { $push: { tags: "sale" } }
$addToSet Appends only if the value isn’t already present { $addToSet: { tags: "sale" } }
$pull Removes matching values from an array { $pull: { tags: "discontinued" } }
$rename Renames a field { $rename: { oldName: "newName" } }

Examples

Example 1: Atomic inventory decrement. Instead of reading the stock count into your application and subtracting one, let the server do it atomically, and use the filter to guard against overselling:

db.products.updateOne(
  { _id: "sku-1001", stock: { $gte: 1 } },
  { $inc: { stock: -1 } }
);

Output:

{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}

Because stock: { $gte: 1 } is part of the filter, this update only matches (and only decrements) if there’s still stock left. If two customers buy the last unit at the same instant, MongoDB serializes the two updates against that document; only one of them sees matchedCount: 1, and the other sees matchedCount: 0 because by the time it runs, stock is already 0. No race, no oversold item, and no application-level locking required.

Example 2: Multiple fields updated together atomically. A single document write can touch several fields and an array in one atomic step:

db.orders.updateOne(
  { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") },
  {
    $set: { status: "shipped", shippedAt: new Date() },
    $push: { history: { status: "shipped", at: new Date() } }
  }
);

Output:

{
  acknowledged: true,
  insertedId: null,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0
}

The status field, the shipped timestamp, and the new history entry all land in the same document in one write. No client will ever observe an order that has status: "shipped" but is missing the corresponding history entry, because both changes commit together or not at all.

Example 3: Atomic read-and-modify with findOneAndUpdate(). This is the right tool whenever your application needs the resulting value immediately, such as reserving the last seat at a concert:

db.events.findOneAndUpdate(
  { _id: "concert-42", seatsAvailable: { $gt: 0 } },
  {
    $inc: { seatsAvailable: -1 },
    $push: { attendees: "user_9231" }
  },
  { returnDocument: "after" }
);

Output:

{
  _id: 'concert-42',
  name: 'Live at the Grand Hall',
  seatsAvailable: 149,
  attendees: [ 'user_8110', 'user_9231' ]
}

If seatsAvailable had already reached zero, the filter would not match, findOneAndUpdate() would return null, and the application would know to reject the reservation — all without a separate read step that could have raced against another booking.

How It Works Step by Step

When the server receives an update command, it first uses the filter to locate the target document, ideally via an index rather than a full collection scan. Once the document is located, the storage engine (WiredTiger, in modern MongoDB) reads the current on-disk representation of that one document, applies the update operators to it in memory, and writes the resulting document back as a single internal storage transaction. Either the entire new version of the document is committed, or, if something goes wrong (a crash, a conflict), none of it is: there is no state where only some of the operators in the update document took effect.

WiredTiger’s MVCC (multi-version concurrency control) model means readers see a consistent snapshot and are never exposed to a document mid-write. This holds even for a complex update touching several nested fields and array elements at once, because from the storage engine’s point of view it is still one write to one document.

For updateMany(), the server repeats this process once per matched document. Each individual document update is atomic, but the command as a whole loops through matches one at a time, so there is a window during execution where some matched documents reflect the new state and others don’t yet. That is expected behavior, not a bug — but it’s a detail that trips people up, covered further below.

Common Mistakes

Mistake 1: The classic read-modify-write race. Fetching a value, changing it in application code, and writing it back looks harmless but creates a gap where another request can interleave:

// WRONG: two separate round trips create a race condition
const account = await db.accounts.findOne({ _id: "acct-77" });
const newBalance = account.balance - 50;
await db.accounts.updateOne(
  { _id: "acct-77" },
  { $set: { balance: newBalance } }
);

If two withdrawals run concurrently, both might read the same starting balance, both compute the same newBalance, and one withdrawal silently disappears (a classic “lost update”). The fix is to never compute the new value in your application — let the atomic operator do the arithmetic on the server, guarded by a filter condition:

// CORRECT: single atomic operator, no read needed
await db.accounts.updateOne(
  { _id: "acct-77", balance: { $gte: 50 } },
  { $inc: { balance: -50 } }
);

Mistake 2: Assuming updateMany() is atomic across the whole batch. It’s tempting to think a bulk update behaves like a single all-or-nothing transaction:

// Each matched document is updated atomically,
// but the batch as a whole is not one atomic unit
db.warehouseStock.updateMany(
  { warehouse: "west" },
  { $inc: { reserved: 10 } }
);
// A concurrent reader can see some documents already updated
// and others not yet touched while this command is still running.

If your logic depends on every matched document changing together as a unit (for example, reserving stock across several warehouse documents for one order), document-level atomicity is not enough — that calls for a multi-document transaction, covered in the next lesson.

Mistake 3: Splitting one logical change into two separate write calls. Recording a sale by decrementing stock and then writing a log entry as two independent operations leaves a gap where a crash between them creates an inconsistent state:

await db.products.updateOne(
  { _id: "sku-1001" },
  { $inc: { stock: -1 } }
);
// If the process crashes here, the sale is never recorded
await db.salesLog.insertOne({
  sku: "sku-1001",
  qty: 1,
  soldAt: new Date()
});

When both pieces of data belong in the same document, combine them into one atomic update, the way Example 2 combined $set and $push. When they genuinely must live in separate documents or collections, don’t lean on document-level atomicity at all — wrap both writes in a multi-document transaction instead.

Best Practices

  • Prefer atomic operators ($inc, $mul, $push, $addToSet) over reading a value into your application, changing it, and writing it back.
  • Use findOneAndUpdate() whenever your next step depends on the value you just wrote, instead of a separate find followed by an update.
  • Add a guard condition to the filter (like stock: { $gte: 1 }) so an update only succeeds when the precondition still holds, and check matchedCount or the returned document to detect when it didn’t.
  • Design documents so data that must change together atomically lives in the same document, when the data doesn’t grow unboundedly.
  • Remember that updateMany() is atomic per matched document, not across the whole batch — don’t rely on it behaving like a single all-or-nothing operation.
  • Reach for a multi-document transaction only when the atomicity you need genuinely spans more than one document or collection.

Practice Exercises

1. Write an atomic updateOne() on a db.posts collection that adds a user’s id to a likedBy array only if it isn’t already present, so a user can never like the same post twice. Hint: one operator handles this without any duplicate check in your application code.

2. Using findOneAndUpdate(), write a query against a db.coupons collection that decrements a usesRemaining field only when it is greater than zero, and returns the updated document so the caller can confirm whether the coupon was actually applied. Expected result shape: either the updated coupon document, or null if it had already been fully used.

3. A bank app needs to move 100 units from one account document to a different account document. Explain why document-level atomicity alone cannot guarantee this transfer is safe, and name the MongoDB feature that would guarantee it.

Summary

  • MongoDB guarantees that a write to a single document is fully atomic, no matter how many fields, subdocuments, or array elements it touches.
  • Atomic update operators ($set, $inc, $push, and others) let the server apply a change in place, avoiding the race conditions inherent in reading a value, changing it, and writing it back.
  • findOneAndUpdate() combines an atomic read and write in one round trip, which is exactly what you need when the next decision depends on the value just written.
  • Atomicity applies per document: updateMany() updates each matched document atomically, but the batch as a whole is not a single atomic unit.
  • When atomicity truly needs to span multiple documents or collections, document-level atomicity isn’t enough — that’s what multi-document transactions are for.