MongoDB Mastery
MongoDB Mastery lessons / Module 7: Updating & Deleting

Deleting data safely

Level 1: Beginner12 min

deleteOne, deleteMany, drop, TTL expiry and soft deletes, plus a checklist for destructive operations in production.

This lesson is interactive. Run every example against an in-browser MongoDB, see explain() plans drawn as trees, and take the quiz.
Open the interactive lesson
You will learn to
  • Delete with precise filters
  • Choose between deleteMany, drop and TTL indexes
  • Implement soft deletes
  • Follow a safe procedure for bulk deletes

conceptThe concept

  • deleteOne(filter): removes the first match. deleteMany(filter): removes all matches. deleteMany({}) empties the collection but keeps indexes and options.
  • drop(): removes the collection, its indexes and its metadata; much faster than deleting every document.
  • TTL indexes delete documents automatically after a date field is older than expireAfterSeconds (a background task runs every 60 seconds).
  • Soft delete: set deletedAt instead of deleting, and filter it out (often with a partial index).

whyWhy it matters

Deletes are irreversible without a backup. Large deletes also generate one oplog entry per document, increase replication lag, and leave free space inside data files rather than returning it to the OS.

Use caseReal-world scenario

Purging 30 million old events: instead of one giant deleteMany, a job deletes in batches of 10,000 by _id range during off-peak hours while watching replication lag, or better, the data was designed with a TTL index or one collection per month so old data is removed with a cheap drop().

Try itExamples

Run and edit these in the interactive lesson.

Preview, then delete
db.lab_people.find({ email: /example\.com$/ }, { name: 1, email: 1 })
db.lab_people.countDocuments({ name: /^B/ })
db.lab_people.deleteMany({ name: /^B/ })
db.lab_people.countDocuments()
Soft delete
db.customers.updateOne({ _id: 25 }, { $set: { deletedAt: new Date() } })
db.customers.countDocuments({ deletedAt: { $exists: false } })
TTL index
db.lab_sessions.drop()
db.lab_sessions.createIndex({ lastSeen: 1 }, { expireAfterSeconds: 3600 })
db.lab_sessions.insertMany([
  { user: 1, lastSeen: new Date(Date.now() - 2 * 3600 * 1000) },
  { user: 2, lastSeen: new Date() }
])
db.lab_sessions.getIndexes()
Run db.lab_sessions.find() again: the simulator runs the TTL monitor before each run, so the 2-hour-old session disappears, just as mongod removes it within about a minute.

internalsUnder the hood

Each deleted document removes index entries and adds an oplog entry. WiredTiger marks the space as reusable inside the file; compact (online since 4.4) or an initial sync is needed to give space back to the filesystem.

Avoid / preferDo this, not that

Avoid
db.orders.deleteMany({ status: "cancelled" })   // 40 million, at peak
Prefer
let n
do {
  const ids = db.orders.find({ status: "cancelled" }, { _id: 1 }).limit(1000).toArray().map(d => d._id)
  n = db.orders.deleteMany({ _id: { $in: ids } }).deletedCount
  sleep(100)
} while (n > 0)

Every deleted document is its own oplog entry. One huge delete floods replication and churns the cache; small batches with pauses keep secondaries and latency healthy.

Avoid
db.users.deleteOne({ name: "Sam" })   // which Sam?
Prefer
db.users.deleteOne({ _id: ObjectId("66f1c0ffee0000000000abcd") })

deleteOne removes whichever matching document it finds first. Delete by a unique key so you know exactly what goes.

PitfallsCommon mistakes

  • Running deleteMany with a filter you never tested with find/countDocuments.
  • Typing deleteMany({}) in the wrong database or collection.
  • Expecting TTL deletion to be immediate; it runs every 60 seconds and can fall behind under heavy load.
  • Deleting tens of millions of documents in one call and causing replication lag.

LimitsWhen not to use it

Deleting is not archiving. If you may need the data again, move it first ($merge into an archive collection) or use a soft-delete flag. For data that simply expires, a TTL index or time series expireAfterSeconds is cheaper than scheduled deletes, and dropping a whole time-partitioned collection is cheapest of all.

SecuritySecurity notes

Deleted documents live on in backups, in the oplog until it rolls over, and in free space on disk until it is reused. A right-to-erasure process must cover backups too. Most application roles do not need the remove action at all; grant it only where deletes are part of the job.

ChallengeTry it yourself

Copy the cancelled orders into a scratch collection cancelled_lab, delete the ones created before 2025 from the copy, and return how many were deleted and how many remain.

Show one solution
One solution
db.cancelled_lab.drop()
db.orders.aggregate([{ $match: { status: "cancelled" } }, { $out: "cancelled_lab" }])
const r = db.cancelled_lab.deleteMany({ createdAt: { $lt: ISODate("2025-01-01") } })
const summary = { deleted: r.deletedCount, remaining: db.cancelled_lab.countDocuments() }
summary

InterviewQuestions you should be able to answer

How would you remove 500 million old documents from a busy production collection?

Prefer designs that make it cheap: time-partitioned collections you can drop(), or a TTL index going forward. For a one-off purge, delete in bounded batches (by _id or date ranges using an index), throttle based on replication lag and cache dirty percentage, run off-peak, and take a backup first. Expect the files not to shrink until you run compact or resync members.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.