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.
The member document
Section titled “The member document”Each lesson seeds its own data. One member bundles the fines, the books on loan and a small activity log:
db.members.insertOne({ name: "Ada", fines: 4, borrowed: ["Algorithms"], activity: { lastVisit: 2025, visits: 12 },});assert.strictEqual(db.members.countDocuments({ name: "Ada" }), 1);One write, many changes, still atomic
Section titled “One write, many changes, still atomic”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:
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);const members = db.collection("members");await members.insertOne({ name: "Ada", fines: 4, borrowed: ["Algorithms"], activity: { lastVisit: 2025, visits: 12 } });const res = await members.updateOne( { name: "Ada" }, { $inc: { fines: -2, "activity.visits": 1 }, $set: { "activity.lastVisit": 2026 }, $pull: { borrowed: "Algorithms" } });console.log(res);const ada = await members.findOne({ name: "Ada" }, { projection: { _id: 0 } });console.log(ada);if (ada.fines !== 2 || ada.borrowed.length !== 0 || ada.activity.visits !== 13) throw new Error("update not applied as a unit");db.members.update_one( {"name": "Ada"}, { "$inc": {"fines": -2, "activity.visits": 1}, "$set": {"activity.lastVisit": 2026}, "$pull": {"borrowed": "Algorithms"}, },)_, err := coll.UpdateOne( ctx, bson.M{"name": "Ada"}, bson.M{ "$inc": bson.M{"fines": -2, "activity.visits": 1}, "$set": bson.M{"activity.lastVisit": 2026}, "$pull": bson.M{"borrowed": "Algorithms"}, },)if err != nil { return err}coll.update_one( doc! { "name": "Ada" }, doc! { "$inc": { "fines": -2, "activity.visits": 1 }, "$set": { "activity.lastVisit": 2026 }, "$pull": { "borrowed": "Algorithms" } },).await?;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).
A failing update changes nothing
Section titled “A failing update changes nothing”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:
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 }}Model so related data lives together
Section titled “Model so related data lives together”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
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.
The race that atomic operators prevent
Section titled “The race that atomic operators prevent”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:
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.
updateMany is many atomic writes
Section titled “updateMany is many atomic writes”“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:
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.
Tips and gotchas
Section titled “Tips and gotchas”- Atomicity is per document, not per operation: a single
updateManyover 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.
Reading AI-generated code
Section titled “Reading AI-generated code”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:
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: 10Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Embed data in a single document | Free atomicity, no session, no commit, the lowest latency possible | The 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 transaction | Only works when the condition concerns the same document |
| Split data across documents/collections, using a multi-document transaction when they must change together | Flexible to query and scale each part independently | Loses the free atomicity, paying session and commit overhead every time a joint change is needed |
Common Misconceptions
Section titled “Common Misconceptions”- “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, orfindOneAndUpdate. - “
updateManyis 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.