07 · Deletes, Replaces & Bulk Writes¶
This lesson finishes basic CRUD with the operations that remove or overwrite data, then covers sending many writes at once — which is how imports, sync jobs and batch processes should talk to MongoDB. The interesting part is not the happy path; it is what happens when one write in a batch fails.
Everything runs in a database called bulk.
Deleting¶
Like updates, deletes report a count rather than failing when nothing matched. If deleting nothing is
an error for your application (deleting an order that doesn't exist), check deletedCount yourself.
findOneAndDelete removes one document and returns it, atomically — useful for "take the next job
from the queue":
The sort decides which matching document is taken when several match.
Before you run a delete¶
A delete filter is the most dangerous thing you will type. Two habits prevent most disasters:
- Run the filter as a
countDocuments()orfind()first and check the number is what you expect. - Never build a delete filter from user input without validating it isn't empty. In most drivers
{}is a perfectly valid filter:deleteMany({})removes everything, and a filter like{ userId: req.query.id }turns into{ userId: undefined }when the parameter is missing — which drivers may serialize in surprising ways depending on settings. Validate first.
deleteMany({}) versus drop()¶
Both leave you with no documents. They are very different operations. With 200,000 small documents and one secondary index in each of two collections:
let t = Date.now(); d.a.deleteMany({});
print("deleteMany ms", Date.now() - t, "indexes left", d.a.getIndexes().length);
t = Date.now(); d.b.drop();
print("drop ms", Date.now() - t, "collections:", d.getCollectionNames().join(","));
deleteMany({}) deletes documents one at a time, writes an oplog entry for each (on a replica set,
every secondary repeats the work), and leaves the collection, its indexes, options and validation rules
in place. drop() removes the collection's files in one metadata operation; indexes and settings go
with it. On a laptop the difference is 650 ms vs 7 ms; on 500 million documents it is hours versus
milliseconds — and hours of replication lag. To empty a large collection and keep its definition, it
is usually faster to drop and recreate indexes (or create a fresh collection and rename it).
Replacing a document¶
replaceOne swaps the whole document (except _id) for a new one:
Every field not in the replacement is gone. _id is immutable — try to change it and the server
refuses:
66 Plan executor error during update :: caused by :: After applying the update, the (immutable) field '_id' was found to have been altered to _id: 22
And a replacement containing operators is rejected client-side, the mirror image of the protection
updateOne gives you:
Use replaceOne when your application owns the whole document and writes it back as a unit (a
settings object, a cached API response). Use updateOne with operators when several writers may change
different fields — a replace would overwrite their changes with whatever your copy had.
To "change" an _id, insert a new document with the new _id and delete the old one, inside a
transaction if nothing may see the in-between state (Level 3 · 06).
Batches: ordered by default¶
insertMany sends many documents in one request. With _ids 1 and 2 already present, insert 3, 1, 4:
try { db.t.insertMany([{ _id: 3 }, { _id: 1 }, { _id: 4 }]) }
catch (e) { print(e.name, "inserted:", e.result.insertedCount,
"errors:", e.writeErrors.map(w => w.index + ":" + w.code).join(",")) }
print(db.t.find().toArray().map(x => x._id).join(","))
The batch is ordered by default: the server processes it in sequence and stops at the first error. Document 3 was inserted, document 1 failed with a duplicate key (index 1 in the batch, code 11000), and document 4 was never attempted. Nothing was rolled back — 3 stays.
Unordered batches keep going¶
try { db.t.insertMany([{ _id: 5 }, { _id: 2 }, { _id: 6 }, { _id: 1 }, { _id: 7 }], { ordered: false }) }
catch (e) { print(e.name, "inserted:", e.result.insertedCount,
"errors:", e.writeErrors.map(w => w.index + ":" + w.code).join(",")) }
With ordered: false the server attempts every write and reports every failure by its index in the
batch. That is what you want for imports and idempotent syncs ("insert these, skip the ones that already
exist"). Unordered batches can also be faster, because on a sharded cluster the router can send the
writes to different shards in parallel. Use ordered batches only when a later write depends on an
earlier one.
Either way, a batch is not a transaction. Writes that succeeded stay applied when later ones fail.
bulkWrite: mixed operations in one round trip¶
bulkWrite batches different kinds of writes against one collection:
const r = db.t.bulkWrite([
{ insertOne: { document: { _id: 10, s: "a" } } },
{ updateOne: { filter: { _id: 1 }, update: { $set: { s: "b" } } } },
{ updateMany: { filter: { _id: { $gte: 5 } }, update: { $set: { batch: true } } } },
{ replaceOne: { filter: { _id: 2 }, replacement: { s: "replaced" } } },
{ deleteOne: { filter: { _id: 3 } } },
{ updateOne: { filter: { _id: 99 }, update: { $set: { s: "new" } }, upsert: true } }
]);
{
acknowledged: true,
insertedCount: 1,
insertedIds: { '0': 10 },
matchedCount: 6,
modifiedCount: 6,
deletedCount: 1,
upsertedCount: 1,
upsertedIds: { '5': 99 }
}
matchedCount is 6: the first updateOne matched 1, the updateMany matched 4 (5, 6, 7 and 10 —
the document inserted earlier in the same ordered batch), and the replaceOne matched 1. The upsert is
reported separately with its batch index ('5').
Across collections¶
Since MongoDB 8.0 the server also has a client-level bulkWrite command that mixes collections in one
request. mongosh exposes it on the connection:
db.getMongo().bulkWrite([
{ name: "insertOne", namespace: "bulk.orders", document: { _id: 1, total: 20 } },
{ name: "updateOne", namespace: "bulk.customers", filter: { _id: "ana" },
update: { $inc: { orders: 1 } }, upsert: true }
])
{
acknowledged: true,
insertedCount: 1,
matchedCount: 0,
modifiedCount: 0,
deletedCount: 0,
upsertedCount: 1,
insertResults: undefined,
updateResults: undefined,
deleteResults: undefined
}
It saves round trips; it is still not atomic across the two collections. Driver support for the client-level form arrived over several releases, so check your driver's documentation before relying on it.
How It Actually Works¶
The driver turns insertMany, bulkWrite and friends into as few server commands as possible. It
groups consecutive operations of the same kind (in an ordered batch) or all operations of the same
kind (in an unordered one) into insert, update and delete commands, and splits them further so no
single command message exceeds the server's limits (maxWriteBatchSize, 100,000 operations, and the
48 MB message size). That is why a 1-million-document insertMany still works: the driver sends it as
several commands.
On the server, each write in a command is its own atomic single-document operation. For an ordered
command, the server stops at the first failure and returns what it did plus the error; for an unordered
one, it records the failure and continues. The driver then merges the per-command replies into one
result, translating the indexes back to positions in your list — so writeErrors[i].index always
refers to your original array.
A drop is a catalog operation: on a replica set it is replicated as one oplog entry, and the data files
are removed after the drop is majority-committed. A deleteMany is N separate document deletes, each
with its own oplog entry and index maintenance, which is why its cost scales with the data.
Common mistakes¶
- Running a delete without counting the filter first.
- Using
replaceOnewhen other code updates the same document — it silently reverts their fields. - Assuming a failed batch rolled back. Ordered or not, successful writes stay.
- Using ordered batches for imports, so one duplicate stops the whole job half-way.
- Emptying a huge collection with
deleteMany({})when a drop-and-recreate would do. - Reporting "deleted" when
deletedCountis 0.
Exercise¶
- Insert
_ids 1–10. TheninsertManya batch of_ids 8–15 ordered, and again unordered. Predict the final contents and the error indexes before running each. - Write a "sync" script that receives a list of products and, with a single
bulkWrite, upserts every product bysku, settingupdatedAt, and deletes products whoseskuis not in the list (usedeleteManywith$nin). Print the result counts. - Time
deleteMany({})againstdrop()on 500,000 documents on your machine. Then checkdb.getSiblingDB("local").oplog.rs.countDocuments({ns: "bulk.<yourcollection>"})after each and explain the difference. - Use
findOneAndDeletewith a sort to build a tiny job queue: insert five jobs with priorities, and take them off in priority order.