Lesson 8 of 18

Deleting Documents

deleteOne: Remove Exactly One Document

deleteOne(filter) finds the first document matching the filter, removes it, and stops. The result is a small object whose deletedCount tells you whether anything was actually removed. A deletedCount of 0 is not an error — it means nothing matched, and your application usually needs to treat that as "not found" rather than as success.

The word one is a promise about how many documents will disappear, not a promise that it will be the right one. If two customers are both named "Rahul Verma" and you delete by name, MongoDB removes whichever it reaches first, silently. Delete by _id, or by a field that carries a unique index, whenever you intend to remove a specific record. This is the single most useful habit in this lesson.

Deleting does not shrink your indexes to nothing or reclaim disk space immediately — the storage engine reuses the freed space for future writes. That is normal and nothing to worry about; you should not expect the size reported by db.stats() to drop the instant you delete a few thousand documents.

Example
// Precise: delete by _id
db.users.deleteOne({ _id: ObjectId("66ab12cd34ef56789012ab34") })
// { acknowledged: true, deletedCount: 1 }

// Nothing matched
db.users.deleteOne({ email: "nobody@example.com" })
// { acknowledged: true, deletedCount: 0 }

// Risky: two people can share a name
db.users.deleteOne({ name: "Rahul Verma" })   // removes ONE of them, silently
Notes
  • In Node.js, check the result: const { deletedCount } = await users.deleteOne({ _id: id }); if (!deletedCount) return res.status(404).send('Not found'). Reporting success for a delete that removed nothing is a bug users will notice before you do.

deleteMany: Powerful, and Genuinely Dangerous

deleteMany(filter) removes every matching document. Used well, it is how you clear expired sessions, remove test data, or purge records from a cancelled import. Used carelessly, it is how a collection disappears.

The specific mistake to burn into memory is deleteMany({}). An empty filter matches every document, so this empties the entire collection with no confirmation prompt, no dry run and no undo. It is the exact same shape as a normal call — the only difference is a filter you forgot to fill in. In code, an equally common version is passing a filter built from a variable that turned out to be undefined, which can collapse into an empty object and take the whole collection with it.

The habit that prevents this is short. Run the filter through countDocuments first and read the number. If you expected to delete forty rows and the count says nineteen thousand, you have just saved yourself. Then run find() with the same filter and look at a couple of the documents. Only then delete.

Example
// 1. Count first — does this number look right?
db.sessions.countDocuments({ expiresAt: { $lt: new Date() } })
// 412

// 2. Eyeball a few of them
db.sessions.find({ expiresAt: { $lt: new Date() } }).limit(3)

// 3. Now delete
db.sessions.deleteMany({ expiresAt: { $lt: new Date() } })
// { acknowledged: true, deletedCount: 412 }

// The one to be afraid of: empties the collection
// db.sessions.deleteMany({})
Notes
  • For data that should expire on a schedule, do not write a delete job at all. A TTL index — db.sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }) — makes MongoDB remove documents once that date has passed, with no cron job to forget about.

findOneAndDelete: Take the Document With You

findOneAndDelete removes a document and returns it. That sounds like a convenience, and for logging or for showing the user what was cancelled it is — but its real value is atomicity. Fetching a document and then deleting it takes two separate operations, and between them another process can fetch the same document. Doing both as one operation makes that impossible.

This is why it is the standard way to build a simple job queue on top of MongoDB. Add a sort option to take the oldest pending job, and every worker is guaranteed to claim a different one; whoever loses the race gets a different document or null. Without atomicity, two workers happily process the same job twice.

It returns the deleted document, or null if the filter matched nothing — so a single if handles the empty-queue case.

Example
// Claim and remove the oldest pending job, atomically
const job = db.jobs.findOneAndDelete(
  { status: "pending" },
  { sort: { createdAt: 1 } }
)

if (job) {
  print(`processing ${job._id}`)
} else {
  print("queue is empty")
}

// Also useful for an audit trail: keep a copy before it is gone
const removed = db.users.findOneAndDelete({ _id: ObjectId("...") })
if (removed) db.deletionLog.insertOne({ removed, deletedAt: new Date() })

Which One Should You Use?

Four operations remove data and they are not interchangeable. Picking the right one is mostly a question of how much you are removing and whether you want anything back.

  • deleteOne(filter) — one specific record, when you do not need its contents afterwards. Filter by _id.
  • findOneAndDelete(filter, { sort }) — one record you want returned, or where two processes might race for the same document.
  • deleteMany(filter) — a set of records identified by a condition. Count first, every time.
  • drop() — the whole collection, including its indexes and validator. Much faster than deleteMany({}) on large data, but you must recreate the indexes afterwards.
  • A TTL index — data that should disappear on its own after a fixed time, so no delete code exists to go wrong.
  • None of the above — see soft deletes below, which is what production systems usually do.
Notes
  • deleteMany({}) and drop() both leave you with an empty collection, but only deleteMany preserves your indexes and validation rules. If a script drops a collection during testing, make sure the same script recreates the indexes, or your queries will quietly get slower over time.

Soft Deletes: Why Real Systems Rarely Delete

In most applications, deleting a row is the wrong implementation of "delete". If a user cancels an order, you still need it for accounting. If an admin removes an employee, you may need to prove later who did that and when. And if someone deletes the wrong record, restoring one document from a backup is far harder than flipping a flag back.

A soft delete keeps the document and marks it as removed — typically a boolean and a timestamp. The record is invisible to the application, recoverable in seconds, and still available for reports. The cost is discipline: every query that lists data must now exclude the deleted ones, and forgetting that filter in a single place is how deleted records reappear in the interface.

Where a unique index is involved, soft deletes need one extra step. If email is unique and a user is soft-deleted, that email address is still taken and the person cannot sign up again. A partial index solves it cleanly: apply the uniqueness rule only to documents that are not deleted.

Example
// Mark as deleted instead of removing
db.users.updateOne(
  { _id: ObjectId("...") },
  { $set: { deleted: true, deletedAt: new Date() } }
)

// Every read must exclude them
db.users.find({ deleted: { $ne: true } })

// Undo is trivial
db.users.updateOne({ _id: ObjectId("...") },
  { $set: { deleted: false }, $unset: { deletedAt: "" } })

// Keep email unique only among live users
db.users.createIndex(
  { email: 1 },
  { unique: true, partialFilterExpression: { deleted: false } }
)
Notes
  • MongoDB has no foreign keys and no cascading delete. Removing a user does not remove their orders — those documents keep pointing at an _id that no longer exists, and a $lookup will quietly return an empty array for them. Cleaning up related documents is your application's job, and a multi-document transaction is the way to make sure the cleanup either fully happens or does not happen at all.
Ask AI