Skip to content

Single-document atomicity

The strongest guarantee MongoDB gives you is also the quietest, because it costs nothing and asks nothing of you. Every write to a single document happens as one indivisible step. If your update sets three fields, increments a counter, and pulls from an array all at once, every reader sees either none of those changes or all of them — never a half-applied state. And if anything goes wrong mid-write, the document is left exactly as it was before. This is true no matter how deep the change goes: nested objects and arrays inside the document are part of the same atomic unit. The MongoDB manual puts it in one sentence: “write operations are atomic on the single-document level, even if modifying multiple values” (Atomicity in MongoDB, consulted 2026-09-30).

Every output below was captured on this Mac against mongo 8.3.11 (single-node replica set), mongosh 2.11.1 and Node driver 7.7.0; ids, timings and race counts differ on your machine. The Python, Go and Rust tabs mirror the verified shell/Node call but are not executed by the course’s automated checker.

Each lesson seeds its own data. One member bundles the fines, the books on loan and a small activity log:

mongosh/atomicity-seed.js
db.members.insertOne({
name: "Ada", fines: 4, borrowed: ["Algorithms"],
activity: { lastVisit: 2025, visits: 12 },
});
assert.strictEqual(db.members.countDocuments({ name: "Ada" }), 1);

Suppose Ada returns a book, pays off two fines, and visits the library — three logical changes on one visit. Because they all live in one document, a single update applies them together. There is no transaction to open and no session to manage:

mongosh/atomicity-update.js
const res = db.members.updateOne(
{ name: "Ada" },
{
$inc: { fines: -2, "activity.visits": 1 },
$set: { "activity.lastVisit": 2026 },
$pull: { borrowed: "Algorithms" }
}
);
console.log(res);
const ada = db.members.findOne({ name: "Ada" }, { _id: 0 });
console.log(ada);
assert.strictEqual(ada.fines, 2);
assert.strictEqual(ada.borrowed.length, 0);
assert.strictEqual(ada.activity.visits, 13);

Real output, mongosh then Node (the acknowledgement shapes differ; the document is identical):

{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{
name: 'Ada',
fines: 2,
borrowed: [],
activity: { lastVisit: 2026, visits: 13 }
}
{
acknowledged: true,
modifiedCount: 1,
upsertedId: null,
upsertedCount: 0,
matchedCount: 1
}
{
name: 'Ada',
fines: 2,
borrowed: [],
activity: { lastVisit: 2026, visits: 13 }
}

No reader could ever have seen the document with the fine reduced but the book still on loan. The update operators themselves are atomic, too: $inc reads and adds as one server-side step (proved in the race below).

Atomic also means all-or-nothing on failure. Here the update sets city first and then tries to $inc the string field name. The server rejects the whole update — city never appears:

mongosh/atomicity-fail.js
let message = null;
try {
db.members.updateOne({ name: "Ada" }, { $set: { city: "Arlington" }, $inc: { name: 1 } });
} catch (e) { message = e.message; }
console.log(message);
const after = db.members.findOne({ name: "Ada" }, { _id: 0 });
console.log(after);
assert.ok(message.includes("Cannot apply $inc to a value of non-numeric type"));
assert.strictEqual(after.city, undefined);
Plan executor error during update :: caused by :: Cannot apply $inc to a value of non-numeric type. {_id: ObjectId('6abcc850aeeadcc10fdb2f05')} has the field 'name' of non-numeric type string
{
name: 'Ada',
fines: 2,
borrowed: [],
activity: { lastVisit: 2026, visits: 13 }
}

The practical lesson is a modelling one. When data changes together, store it together, and the free atomicity covers it. Had we split the activity log into a separate activity collection keyed by member, that same visit would span two documents and force a choice between giving up atomicity and paying for a transaction (next lesson).

flowchart LR
  A["One member document"] --> B["fines"]
  A --> C["borrowed array"]
  A --> D["activity object"]
  B --> E["A single update changes all three atomically"]
  C --> E
  D --> E
Embedding related data in one document means one atomic write keeps it all consistent

In Compass: open a member document in the Documents tab, edit several fields and click Update — Compass sends one update for the document, atomic like the driver call above.

Atomic operators matter when many clients write at once. Below, 100 concurrent clients each add 1 to a counter, once as read, compute, write back, once with $inc:

app/atomicity-lost-update.mjs
const counters = db.collection("counters");
await counters.insertMany([{ _id: "read-modify-write", n: 0 }, { _id: "inc", n: 0 }]);
await Promise.all(Array.from({ length: 100 }, async () => {
const doc = await counters.findOne({ _id: "read-modify-write" });
await counters.updateOne({ _id: "read-modify-write" }, { $set: { n: doc.n + 1 } });
}));
await Promise.all(Array.from({ length: 100 }, () =>
counters.updateOne({ _id: "inc" }, { $inc: { n: 1 } })));
const rows = await counters.find().toArray();
console.log(rows);
const byId = Object.fromEntries(rows.map((r) => [r._id, r.n]));
if (byId["inc"] !== 100) throw new Error("$inc must never lose an update");
if (byId["read-modify-write"] >= 100) throw new Error("expected lost updates in the naive version");
[ { _id: 'read-modify-write', n: 1 }, { _id: 'inc', n: 100 } ]

Every write was individually atomic — and most of the 100 increments vanished, because each client wrote back a value computed from a stale read. The atomicity lives in the operator, not in your application’s read-then-write.

“One atomic write” ends at the document boundary. Below, updateMany runs over 300 000 documents while a polling loop (another pooled connection) counts how many already have done: true. The reader sees the batch half-finished:

app/atomicity-updatemany.mjs
const big = db.collection("big");
const docs = Array.from({ length: 300000 }, (_, i) => ({ i, done: false }));
for (let i = 0; i < docs.length; i += 100000) await big.insertMany(docs.slice(i, i + 100000), { ordered: false });
await big.createIndex({ done: 1 });
const seen = [];
let running = true;
const poll = (async () => {
while (running) seen.push(await big.countDocuments({ done: true }));
})();
const t0 = Date.now();
const res = await big.updateMany({}, { $set: { done: true } });
const ms = Date.now() - t0;
running = false;
await poll;
const partial = seen.filter((n) => n > 0 && n < 300000);
console.log({ modifiedCount: res.modifiedCount, ms, samples: seen.length, partialSamples: partial.length, firstPartial: partial.slice(0, 5) });
if (partial.length === 0) throw new Error("expected the reader to observe a partially applied updateMany");
{
modifiedCount: 300000,
ms: 2119,
samples: 172,
partialSamples: 170,
firstPartial: [ 247, 609, 827, 1015, 1169 ]
}

The manual says the same: “the modification of each document is atomic, but the operation as a whole is not atomic” (Atomicity in MongoDB). When a batch must be all-or-nothing, use a multi-document transaction.

  • Atomicity is per document, not per operation: a single updateMany over a thousand documents is a thousand independent atomic writes.
  • Embedding is not free in every case. A document that grows without bound (an activity log with millions of entries) approaches the 16 MiB limit — split it out and accept a different consistency strategy.
  • “Atomic” does not mean “isolated from other documents”: if your logic reads document B to decide what to write to document A, there is still a race.

An assistant asked for “safe stock decrement” writes this. It reads like a guard, and the comment sounds confident:

🐛 Spot the bug

// AI comment: "single-document writes are atomic, so this can never oversell"
async function buy(stock, sku) {
const item = await stock.findOne({ _id: sku });
if (item.left > 0) {
await stock.updateOne({ _id: sku }, { $inc: { left: -1 } });
return true;
}
return false;
}
Show the answer

Each updateOne is atomic, but the check and the write are two separate operations, so 50 concurrent buyers all pass item.left > 0 before any decrement lands. In the run below 10 items in stock were “sold” 50 times and the counter went negative; the fix moves the check into the filter, so the server tests and decrements in one atomic step:

app/atomicity-oversell.mjs
const stock = db.collection("stock");
await stock.insertMany([{ _id: "naive", left: 10 }, { _id: "guard", left: 10 }]);
await Promise.all(Array.from({ length: 50 }, async () => {
const item = await stock.findOne({ _id: "naive" });
if (item.left > 0) await stock.updateOne({ _id: "naive" }, { $inc: { left: -1 } });
}));
const results = await Promise.all(Array.from({ length: 50 }, () =>
stock.updateOne({ _id: "guard", left: { $gt: 0 } }, { $inc: { left: -1 } })));
const sold = results.reduce((n, r) => n + r.modifiedCount, 0);
const rows = await stock.find().toArray();
console.log(rows, "guarded sales:", sold);
if (rows.find((r) => r._id === "naive").left >= 0) throw new Error("expected the naive version to oversell");
if (rows.find((r) => r._id === "guard").left !== 0 || sold !== 10) throw new Error("guarded version must sell exactly 10");
[ { _id: 'naive', left: -39 }, { _id: 'guard', left: 0 } ] guarded sales: 10

A document is the unit of “all or nothing”: the server applies an update document to one record under that record’s write lock, and a reader gets the version before or after, never between. Everything larger than one document (a batch, a read-then-write, two collections) is a sequence of atomic steps that other clients can interleave with — unless you fold the check into the filter, or use a transaction.

ChoiceBenefitCost
Embed data in a single documentFree atomicity, no session, no commit, the lowest latency possibleThe document grows with the embedded data, approaching the 16 MiB BSON limit and making every write rewrite a bigger document
Guard in the filter ({ left: { $gt: 0 } })Check and write are one atomic step, no transactionOnly works when the condition concerns the same document
Split data across documents/collections, using a multi-document transaction when they must change togetherFlexible to query and scale each part independentlyLoses the free atomicity, paying session and commit overhead every time a joint change is needed
  • “A read-then-write is atomic because it ends in one document write.” — proven false above: 100 clients produced a counter of far less than 100. Use $inc, a filter guard, or findOneAndUpdate.
  • “updateMany is one atomic operation.” — each document is atomic; the batch is not, and a reader saw it half-done.
  • “Atomic means isolated from other documents.” — atomicity covers your own document only; deciding a write from another document is still a race.
  • “Embed everything, atomicity is free.” — an unbounded embedded array heads toward the 16 MiB limit and slows every operation on the document.

Python/Go/Rust tabs mirror the verified shell/Node behaviour; they are not executed by the course’s automated checker.

An update sets two fields and pulls from an array in one document. What can another reader observe?
An update does $set: { city } and then $inc on the string field name. What is in the document afterwards?
100 clients each do findOne, then $set n = doc.n + 1. Why does the counter end far below 100?
Is a single updateMany over many documents one atomic step?
How do you make "decrement stock only if stock > 0" safe under concurrency?