Deleting documents
Deleting is the operation with the least syntax and the highest stakes. There are no operators to learn - you hand MongoDB a filter, and it removes the documents that match. The whole skill is in the filter: a delete is only as precise as the conditions you give it, and an over-broad filter erases data you meant to keep. The good news is that the methods mirror everything you already know: deleteOne removes one match, deleteMany removes every match.
We continue with the members collection. Each example shows what the filter selects and what the acknowledgement reports. Every output was captured on mongo 8.3.11, mongosh 2.11.1 and Node driver 7.7.0 (the Python, Go and Rust tabs were each run once in a scratch program on the same server).
The data we delete
Section titled “The data we delete”Each lesson seeds its own data:
db.members.insertMany([ { name: "Edsger", joined: 2022, fines: 5 }, { name: "Linus", joined: 2023, fines: 2 }, { name: "Margaret", joined: 2023, fines: 0 }, { name: "Grace", joined: 2024, fines: 0 }, { name: "Barbara", joined: 2023, fines: 1 },]);assert.strictEqual(db.members.countDocuments(), 5);await db.collection("members").insertMany([ { name: "Edsger", joined: 2022, fines: 5 }, { name: "Linus", joined: 2023, fines: 2 }, { name: "Margaret", joined: 2023, fines: 0 }, { name: "Grace", joined: 2024, fines: 0 }, { name: "Barbara", joined: 2023, fines: 1 },]);if ((await db.collection("members").countDocuments()) !== 5) throw new Error("expected 5");Deleting a single document
Section titled “Deleting a single document”deleteOne removes one document that matches the filter and stops, even if others would have matched too. That singular-method safety rail is exactly why you reach for it when you intend to remove one specific record. Here we remove the member named Edsger:
const res = db.members.deleteOne({ name: "Edsger" });console.log(res);console.log(db.members.deleteOne({ name: "Nobody" })); // no matchconst many = db.members.deleteOne({ joined: 2023 }); // 3 members match...console.log(many.deletedCount, db.members.countDocuments({ joined: 2023 })); // ...only one goesassert.strictEqual(res.deletedCount, 1);assert.strictEqual(db.members.countDocuments({ joined: 2023 }), 2);const res = await db.collection("members").deleteOne({ name: "Edsger" });console.log(res.deletedCount);if (res.deletedCount !== 1) throw new Error("expected 1 deleted");res = db.members.delete_one({"name": "Edsger"})print(res.deleted_count)res, err := coll.DeleteOne(ctx, bson.M{"name": "Edsger"})if err != nil { return err}fmt.Println(res.DeletedCount)let res = coll.delete_one(doc! { "name": "Edsger" }).await?;println!("{}", res.deleted_count);The acknowledgement reports how many documents were removed. If the filter matches nothing, that is not an error - deletedCount is simply 0. And with three 2023 members matching, deleteOne removed exactly one:
{ acknowledged: true, deletedCount: 1 }{ acknowledged: true, deletedCount: 0 }1 2(The last line is many.deletedCount = 1 and the 2023 members left = 2. Which of the matching documents goes is not specified - do not rely on it; filter by _id when it must be one particular record.)
In Compass: hover over a document in the Documents tab and click the trash-can icon, then confirm; Compass deletes that single document.
Deleting many at once
Section titled “Deleting many at once”deleteMany removes every document matching the filter in one operation. Here we purge everyone who joined before 2024 - note how the filter does the precision work:
const res = db.members.deleteMany({ joined: { $lt: 2024 } });console.log(res);assert.strictEqual(db.members.countDocuments(), 1); // only Grace (joined 2024) is leftconst res = await db .collection("members") .deleteMany({ joined: { $lt: 2024 } });console.log(res.deletedCount);if (res.deletedCount !== 3) throw new Error("expected 3 deleted");res = db.members.delete_many({"joined": {"$lt": 2024}})print(res.deleted_count)res, err := coll.DeleteMany(ctx, bson.M{"joined": bson.M{"$lt": 2024}})if err != nil { return err}fmt.Println(res.DeletedCount)let res = coll.delete_many(doc! { "joined": { "$lt": 2024 } }).await?;println!("{}", res.deleted_count);The count reflects every document that the filter swept up (the shell printed { acknowledged: true, deletedCount: 2 } here because one 2023 member had already gone in the deleteOne example above; a fresh run in Node, Python, Go and Rust reported 3 for the five-member seed minus Edsger).
The empty filter
Section titled “The empty filter”deleteMany({}) matches every document. It is the single most dangerous CRUD call, and the surprises are in how each client treats a missing filter. We ran all of these on scratch data:
db.scratch.insertMany([{ name: "Ada" }, { name: "Grace" }]);db.scratch.createIndex({ name: 1 });
let missing;try { db.scratch.deleteMany(); } catch (e) { missing = e; } // no argument at allconsole.log(missing.name + ": " + missing.message);console.log(db.scratch.countDocuments()); // still 2
const res = db.scratch.deleteMany({}); // explicit empty filterconsole.log(res, db.scratch.countDocuments());console.log("collection still exists:", db.getCollectionNames().includes("scratch"));console.log("indexes still there:", db.scratch.getIndexes().map((i) => i.name));
db.scratch.drop();console.log("after drop, collection exists:", db.getCollectionNames().includes("scratch"));assert.strictEqual(db.scratch.countDocuments(), 0);MongoshInvalidInputError: [COMMON-10001] Missing required argument at position 0 (Collection.deleteMany)2{ acknowledged: true, deletedCount: 2 } 0collection still exists: trueindexes still there: [ '_id_', 'name_1' ]after drop, collection exists: falseSo in mongosh deleteMany() with no argument is refused, and deleteMany({}) empties the collection but keeps it and its indexes; drop() removes the collection and indexes too. The Node driver is less forgiving:
const c = db.collection("scratch");await c.insertMany([{ a: 1 }, { a: 2 }]);const noFilter = await c.deleteMany(); // no argumentconsole.log("deleteMany():", noFilter, "left:", await c.countDocuments());
await c.insertMany([{ a: 1 }, { a: 2 }, { b: 3 }]);const undef = await c.deleteMany({ a: undefined }); // an undefined value inside the filterconsole.log("deleteMany({ a: undefined }):", undef.deletedCount, "left:", await c.countDocuments());if (noFilter.deletedCount !== 2) throw new Error("expected deleteMany() to delete everything");deleteMany(): { acknowledged: true, deletedCount: 2 } left: 0deleteMany({ a: undefined }): 1 left: 2In Node, deleteMany() with no argument deleted both documents - the driver defaults the filter to {} - while mongosh raises an error. And { a: undefined } is serialised as { a: null }, which matches documents where a is null or missing: it deleted { b: 3 } and left the two documents that do have an a. Both are how unset variables turn into data loss.
Emptying is also slower than dropping. On 200 000 documents (with one index) on this Mac: deleteMany({}) took 1001 ms, drop() took 3 ms. If you truly want the collection gone and can recreate its indexes, drop it.
The soft-delete alternative
Section titled “The soft-delete alternative”Sometimes a hard delete is the wrong choice: you may need an audit trail, the ability to undo, or to keep historical reports accurate. The common pattern is a soft delete - instead of removing the document, you mark it with a flag (and often a timestamp) using an update, then exclude flagged documents from your normal reads.
db.softies.insertMany([{ name: "Ada" }, { name: "Linus" }, { name: "Grace" }]);db.softies.updateOne( { name: "Linus" }, { $set: { deleted: true, deletedAt: new Date() } });console.log("no filter :", db.softies.find({}, { _id: 0, name: 1 }).toArray());console.log("deleted $ne true:", db.softies.find({ deleted: { $ne: true } }, { _id: 0, name: 1 }).toArray());console.log("deleted: false :", db.softies.find({ deleted: false }, { _id: 0, name: 1 }).toArray());assert.strictEqual(db.softies.countDocuments({ deleted: { $ne: true } }), 2);assert.strictEqual(db.softies.countDocuments({ deleted: false }), 0);await db.collection("members").insertOne({ name: "Linus", joined: 2023, fines: 2 });await db.collection("members").updateOne( { name: "Linus" }, { $set: { deleted: true, deletedAt: new Date() } });const linus = await db.collection("members").findOne({ name: "Linus" }, { projection: { _id: 0 } });console.log(linus);if (linus?.deleted !== true) throw new Error("expected the flag");from datetime import datetime, timezone
db.members.update_one( {"name": "Linus"}, {"$set": {"deleted": True, "deletedAt": datetime.now(timezone.utc)}},)_, err := coll.UpdateOne( ctx, bson.M{"name": "Linus"}, bson.M{"$set": bson.M{"deleted": true, "deletedAt": time.Now()}},)if err != nil { return err}use mongodb::bson::DateTime;
coll.update_one( doc! { "name": "Linus" }, doc! { "$set": { "deleted": true, "deletedAt": DateTime::now() } },).await?;The document is still there, just flagged; you can restore it later by unsetting the flag. The catch is in the read filter - the three reads above returned:
no filter : [ { name: 'Ada' }, { name: 'Linus' }, { name: 'Grace' } ]deleted $ne true: [ { name: 'Ada' }, { name: 'Grace' } ]deleted: false : []{ deleted: { $ne: true } } is the right exclusion: documents that never had the field also match it. { deleted: false } returned nothing because nobody has deleted: false - it only matches documents that carry the field with that value. (In Node, the flagged document reads { name: 'Linus', joined: 2023, fines: 2, deleted: true, deletedAt: 2026-09-30T06:19:12.301Z }.)
Soft delete now, hard delete later: a TTL index
Section titled “Soft delete now, hard delete later: a TTL index”Soft deletes grow the collection forever unless something purges them. A TTL index on the timestamp turns “recoverable for a while” into a policy: MongoDB removes the document after the window. Here it is with a 1-second window so we can watch it (a real policy would use days):
// app/delete-soft-ttl.mjs @skip-verify waits for the TTL monitor, which wakes only every 60 sconst c = db.collection("members");await c.createIndex({ deletedAt: 1 }, { expireAfterSeconds: 1 });await c.insertMany([{ name: "Ada" }, { name: "Linus" }]);await c.updateOne({ name: "Linus" }, { $set: { deleted: true, deletedAt: new Date() } });console.log("before sweep:", (await c.find({}, { projection: { _id: 0, name: 1 } }).toArray()).map((d) => d.name));// ...poll until the background TTL thread runs...console.log("remaining:", (await c.find().toArray()).map((d) => d.name));before sweep: [ 'Ada', 'Linus' ]Linus gone after 18 s; remaining: [ 'Ada' ]The document vanished 18 s after the flag in this run - the TTL monitor sweeps on a fixed 60 s cycle, so the delay is anywhere from about zero to a minute beyond the window. Documents without deletedAt (Ada) are never touched. The TTL index only works on a real Date value (the indexes module proves that a string date is ignored).
Tips and gotchas
Section titled “Tips and gotchas”deleteMany({})empties the entire collection. mongosh refusesdeleteMany()with no argument, but the Node driver deletes everything when you call it that way (proven above). Never build a delete filter from a variable that might be empty orundefined.- A delete is permanent. Unlike an update, there is no “before” copy left behind unless you arranged one. When in doubt, run the same filter through
find/countDocumentsfirst to see exactly what you are about to remove. deleteOneis your safety rail for “remove one specific record”. Even if your filter is sloppier than you thought, it can only take one document - but which one is unspecified, so filter by_idwhen it matters.- Emptying with
deleteMany({})keeps the collection and its indexes and is much slower thandrop()(1001 ms vs 3 ms for 200 000 documents). - Soft delete trades simplicity for safety: every normal query must remember
deleted: { $ne: true }(notdeleted: false), but you gain undo and an audit trail.
Reading AI-generated code
Section titled “Reading AI-generated code”An assistant writing an admin endpoint often passes user input straight through as the filter:
🐛 Spot the bug
// AI comment: "delete the members matching the query string, e.g. DELETE /members?joined=2022"app.delete("/members", async (req, res) => {const result = await db.collection("members").deleteMany(req.query);res.json({ deleted: result.deletedCount });});Show the answer
req.query is { joined: "2022" } for ?joined=2022 - but for a plain DELETE /members it is {}, and deleteMany({}) matches everything. We ran the Node driver with an empty filter (and with no argument): both deleted every document in the collection with no error, no confirmation. (Even the intended case is wrong: query-string values are strings, so joined: "2022" would never match the numeric field.) The fix is to build the filter explicitly, validate it, and refuse an empty one:
const c = db.collection("guard");await c.insertMany([{ name: "Ada", joined: 2022 }, { name: "Grace", joined: 2024 }]);
async function deleteMembers(query) { const joined = Number(query.joined); if (!Number.isInteger(joined)) throw new Error("joined is required and must be an integer"); return (await c.deleteMany({ joined })).deletedCount;}
let refused = null;try { await deleteMembers({}); } catch (e) { refused = e.message; }console.log({ refused, deleted: await deleteMembers({ joined: "2022" }), left: await c.countDocuments() });if (refused === null || (await c.countDocuments()) !== 1) throw new Error("guard failed");{ refused: 'joined is required and must be an integer', deleted: 1, left: 1}Mental Model
Section titled “Mental Model”A delete is a filter with no undo: the server evaluates the same predicate find would, then removes what matched. Therefore the safest workflow is the same predicate, three times: countDocuments(filter) to see how many, find(filter) to see which, then deleteMany(filter). deleteOne is find(filter).limit(1) followed by removal; the choice between hard delete, soft delete and TTL is really the choice of how long you want the data to remain recoverable.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Hard delete (deleteOne/deleteMany) | Reclaims storage, leaves nothing behind, simplest to reason about | Permanent - no audit trail, no undo |
Soft delete (update a flag such as deleted: true) | Recoverable, keeps a full audit trail and historical data | Every read must filter with $ne: true, or “deleted” documents reappear; the collection keeps growing |
Soft delete + TTL index on deletedAt | Recoverable for a window, then removed automatically | The purge is delayed up to a minute past the window (60 s TTL cycle); works only on real Date values |
deleteMany({}) to empty | Keeps the collection and its indexes | 1001 ms vs 3 ms for drop() on 200 000 documents |
drop() | Instant, removes the collection and its indexes | Indexes and options must be recreated |
Common Misconceptions
Section titled “Common Misconceptions”- “
deleteOneremoves every match.” - it removes one (1 2above: three matched, one went). - “An empty delete filter is refused.” - in mongosh yes; in the Node driver
deleteMany()anddeleteMany({})delete everything. - “No error means it worked.” - a misspelled field gives
deletedCount: 0with no error; always check it. - “
{ deleted: false }finds the non-deleted documents.” - it found none in the run above; use{ deleted: { $ne: true } }. - “
undefinedin a filter is ignored.” - the Node driver serialises it asnull, which also matches missing fields. - “
deleteMany({})is the same as dropping.” - it keeps the collection and indexes, and took 1001 ms against 3 ms.
Real-world example
The MongoDB manual documents the mechanism we observed: “The background task that removes expired documents runs every 60 seconds”, so removal can lag expiry, and under load expired data may exist “for some time beyond the 60 second period” (TTL indexes, consulted 2026-09-30); the indexed field must be a date or an array of dates (Expire data).
Python/Go/Rust tabs mirror the verified shell/Node behaviour; each was run once in a scratch program against the same server (counts 1 and 3, then the soft-delete update) and are not executed by the course’s automated checker. The Rust tab’s import was corrected from use bson::DateTime (which does not compile unless you add bson as a direct dependency) to use mongodb::bson::DateTime.