Deleting data safely
deleteOne, deleteMany, drop, TTL expiry and soft deletes, plus a checklist for destructive operations in production.
- 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
deletedAtinstead 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.
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()db.customers.updateOne({ _id: 25 }, { $set: { deletedAt: new Date() } })
db.customers.countDocuments({ deletedAt: { $exists: false } })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()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
db.orders.deleteMany({ status: "cancelled" }) // 40 million, at peaklet 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.
db.users.deleteOne({ name: "Sam" }) // which Sam?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
deleteManywith a filter you never tested withfind/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
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() }
summaryInterviewQuestions 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.