MongoDB: How the deleteOne() / deleteMany() (DELETE) Works Internally
Table of Contents
1. The Command We Will Follow
The DELETE equivalent in MongoDB is deleteOne() for a single document and deleteMany() for multiple documents. In this post we follow both commands step by step, using the students collection:
| MongoDB | Oracle |
|---|---|
| |
The command says: find the matching document(s) and remove them from the collection. If no document matches, nothing happens and no error is raised.
Key differences from Oracle:
- MongoDB deletes are auto-committed per operation. There is no separate
COMMITunless you use a multi-document transaction. - MongoDB does not have a
TRUNCATEcommand. To remove all documents efficiently, you can usedb.students.drop()(which drops the entire collection including indexes) and then recreate it, or usedeleteMany({})(which removes documents one by one and is slower for large collections). - Unlike Oracle, where a
DELETEwithout aWHEREclause is common, in MongoDB you must pass an explicit empty filter{}todeleteMany()to delete all documents. This is a safety measure to prevent accidental data loss.
2. Overall Flowchart
This is the path of the delete command from the client to the acknowledgment. Like updateOne(), a delete has two major phases: a FIND phase (steps 4–6) to locate the documents, and a DELETE phase (steps 7–8) to remove them.
+----------------------------------------------+
| 1. CLIENT (mongosh / driver) |
| db.students.deleteOne( |
| { name: "Ravi" } |
| ) |
+-----------------------+----------------------+
| delete command (BSON over TCP)
v
+----------------------------------------------+
| 2. mongod: NETWORK LAYER AND SESSION |
| accepts the connection, creates an opid |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 3. AUTHORIZATION CHECK | NO ──▶ Unauthorized error (stop)
| does the user have the remove action? |
+-----------------------+----------------------+
| YES
v
+==============================================+
║ FIND PHASE ║
+==============================================+
|
v
+----------------------------------------------+
| 4. PARSE AND VALIDATE THE FILTER | FAIL ──▶ error (stop)
| filter becomes a match expression tree | e.g. unknown operator
| empty filter {} is valid for deleteMany |
| empty filter {} is NOT valid for |
| deleteOne (must match exactly one) |
+-----------------------+----------------------+
| OK
v
+----------------------------------------------+
| 5. LOCK |
| intent-exclusive (IX) lock on DB and |
| collection |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 6. QUERY PLANNER + FIND MATCHING DOCS | uses the same planner as find()
| plan cache or trial run | IXSCAN or COLLSCAN
| locate the document(s) to delete | for deleteOne: stop after 1 match
+-----------------------+----------------------+
|
v
+==============================================+
║ DELETE PHASE ║
+==============================================+
|
v
+----------------------------------------------+
| 7. FOR EACH MATCHING DOCUMENT: |
| a) read the document from storage |
| b) mark the record as DELETED in the |
| collection B-tree (tombstone) |
| c) remove ALL index entries for this |
| document from EVERY index B-tree |
| d) write to journal (WAL) |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 8. OPLOG ENTRY (replica set only) |
| write to local.oplog.rs |
| op: "d", o: { _id: ... } |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 9. WAIT FOR WRITE CONCERN | w:1 ──▶ primary acknowledged
| w:1 (default), w:"majority", w:0 | w:majority ──▶ wait for secondaries
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 10. RELEASE LOCKS |
| release IX lock on collection and DB |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| 11. RESPONSE TO CLIENT |
| { acknowledged: true, |
| deletedCount: 1 } |
+-----------------------+----------------------+
No matching document?
deletedCount: 0
No error is raised — this is normal behavior.
Space reclamation?
The tombstone is cleaned up later during
WiredTiger eviction or checkpoint.
3. Step-by-Step Explanation
Step 1 — The client builds the command
mongosh turns deleteOne() or deleteMany() into a delete command document that includes the collection name and an array of delete statements. Each statement has a filter q and a limit field (limit: 1 for deleteOne(), limit: 0 for deleteMany(), where 0 means “no limit”). The command is sent to mongod immediately as a BSON message.
// What the driver actually sends for deleteOne:
{
delete: "students",
deletes: [
{ q: { name: "Ravi" }, limit: 1 }
],
$db: "myFirstDB"
}
// What the driver sends for deleteMany:
{
delete: "students",
deletes: [
{ q: { age: { $lt: 21 } }, limit: 0 }
],
$db: "myFirstDB"
}Step 2 — mongod receives the request
The network layer accepts the message and creates an operation context with an opid. You can see active deletes by running db.currentOp().
Step 3 — Authorization check
MongoDB checks that the user’s roles include the remove action on myFirstDB.students. Note that the action is called remove, not delete, in MongoDB’s privilege system. If the privilege is missing, the command stops with a not authorized error.
Step 4 — Parse and validate the filter
The filter is parsed into a match expression tree, exactly like find() and the find phase of updateOne(). The same validation rules apply: unknown operators produce an error, and the query shape is extracted for the plan cache.
- For
deleteMany({}), the empty filter is valid and matches all documents. - For
deleteOne(), the filter must be specific enough to identify the intended document. MongoDB does not reject a broad filter ondeleteOne(), but it will only delete the first document it finds (which may not be deterministic without a sort). - If you need to delete a specific document by its
_id, usedeleteOne({ _id: ObjectId("...") }). This is the fastest and safest approach because the_idindex is always present.
Step 5 — Take locks
MongoDB takes intent-exclusive (IX) locks on the database and the collection. These are the same locks used by insertOne() and updateOne(). They allow concurrent operations on different documents but prevent conflicting schema changes on the collection.
Step 6 — Query planner finds the matching documents
This step reuses the same query planner that find() uses (see the find() post, Section 4). MongoDB checks the plan cache for the filter’s query shape, or runs a trial of candidate plans.
- For
deleteOne(), MongoDB stops scanning as soon as it finds one matching document. If an index onnameexists, the planner will likely choose anIXSCANon that index. - For
deleteMany(), MongoDB continues scanning until all matching documents are found. If no suitable index exists, aCOLLSCANis performed. - The choice of index can significantly affect delete performance. Deleting 10,000 documents with a
COLLSCANon a million-document collection is much slower than using an index that directly identifies the 10,000 documents.
Step 7 — Delete each matching document
For each document found in Step 6, MongoDB performs the following sub-steps:
- Read the document from the WiredTiger cache (or from disk if the page is not cached). The document is needed to identify all index entries that must be removed.
- Mark the record as deleted in the collection B-tree. WiredTiger does not immediately remove the record from the B-tree leaf page. Instead, it places a tombstone (a deletion marker) on the record. The space is reclaimed later during eviction or checkpoint (see Section 4 for details).
- Remove all index entries for this document from every index on the collection. Unlike
updateOne(), where only changed fields trigger index updates, a delete must remove all index entries for the document because the document no longer exists. This includes the default_idindex and any secondary indexes. - Write to the journal (WAL) for crash recovery. The journal records the deletion operation so it can be replayed if the server crashes before the next checkpoint.
Step 8 — Write the oplog entry (replica set only)
In a replica set, MongoDB writes an oplog entry to local.oplog.rs. The oplog entry type is "d" (delete), and the entry contains the _id of the deleted document:
// Example oplog entry for a delete:
{
ts: Timestamp(1700000000, 1),
op: "d",
ns: "myFirstDB.students",
o: { _id: ObjectId("65a1b2c3d4e5f6a7b8c9d0e1") },
wall: ISODate("2024-01-15T10:30:00Z")
}Note that the oplog entry contains only the _id, not the full document. This is because secondaries only need the _id to find and delete the same document on their side. This makes delete oplog entries very compact compared to insert or replace oplog entries.
Step 9 — Wait for write concern
Same as insertOne() and updateOne(). The default w: 1 acknowledges as soon as the primary has written the data. w: "majority" waits for a majority of replica set members. j: true ensures the journal is flushed to disk.
Step 10 — Release locks
The IX locks on the collection and database are released. The deleted document is no longer visible to other operations (subject to their read concern and snapshot).
Step 11 — Send the response to the client
The response includes a single counter:
deletedCount: how many documents were actually deleted. If no documents matched the filter,deletedCountis 0 and no error is raised. This is different from Oracle, where aDELETEthat affects 0 rows still succeeds butSQL%ROWCOUNTreturns 0.
4. How the Storage Engine Handles the Delete
This is Step 7b in detail. WiredTiger uses a tombstone mechanism rather than immediately removing the record from the B-tree. This is essential for MVCC — other transactions that started before the delete may still need to read the old version of the document.
+----------------------------------------------+
| Application calls deleteOne() |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| READ PHASE |
| WiredTiger: read the document from the |
| collection B-tree (cache or disk) |
| The full document is needed to identify |
| all index entries to remove |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| TOMBSTONE PHASE (collection B-tree) |
| |
| Before: |
| Leaf page: [docA] [docB] [docC] [docD] |
| ↑ |
| delete docC |
| |
| After: |
| Leaf page: [docA] [docB] [☠ docC] [docD] |
| ↑ |
| tombstone |
| |
| The record is NOT physically removed yet. |
| It is marked with a deletion timestamp. |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| INDEX CLEANUP (every index B-tree) |
| |
| For each index on the collection: |
| 1. Find all entries pointing to docC |
| 2. Mark each entry as deleted (tombstone) |
| |
| _id index: remove (_id:3 → docC) |
| age index: remove (22 → docC) |
| name index: remove ("Ravi" → docC) |
| grade index: remove ("A" → docC) |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| JOURNAL (WAL) |
| Write the delete record to the journal. |
| Flushed to disk every 100 ms (default) |
| or immediately if j:true. |
+-----------------------+----------------------+
|
v
+----------------------------------------------+
| SPACE RECLAMATION (later, background) |
| |
| During eviction or checkpoint: |
| 1. WiredTiger checks if any active |
| snapshot still needs the old version |
| 2. If no snapshot needs it, the tombstone |
| and the record data are removed |
| 3. The freed space on the B-tree page |
| becomes available for new records |
| |
| This is NOT immediate. A large delete |
| operation may not free disk space right away.|
+----------------------------------------------+
Key points:
- Tombstone, not immediate removal: WiredTiger marks the record as deleted but keeps it in the B-tree until no active snapshot needs it. This is required for MVCC — a query that started before the delete must still be able to read the document.
- Space reclamation is deferred: The physical space is reclaimed during WiredTiger’s eviction process or during a checkpoint. This means that after a large
deleteMany(), the data files on disk may not shrink immediately. This is similar to Oracle, where deleted rows leave free space in blocks that is reused later but does not reduce the tablespace size. - Compaction: If you need to reclaim disk space after a large delete, you can run the
compactcommand:db.runCommand({ compact: "students" }). This rewrites the collection and its indexes, removing all tombstones and freeing space. Note thatcompactlocks the collection and is an expensive operation — run it during a maintenance window. - Journal: The journal records the logical delete operation (the
_idof the deleted document). On crash recovery, WiredTiger replays the journal from the last checkpoint to re-apply any deletes that were in memory but not yet in the data files. - MVCC visibility: After the delete, new queries (with a new snapshot) will not see the deleted document. Queries that started before the delete (with an older snapshot) will still see it. This is the same read-consistency model used by Oracle’s undo-based read consistency.
Comparison with Oracle:
| MongoDB (WiredTiger) | Oracle |
|---|---|
| Tombstone on the B-tree record | Row marked as deleted in the data block (ITL entry updated, row flag set) |
| Old version retained for MVCC snapshots | Old version retained in undo segment for read consistency |
| Space reclaimed during eviction / checkpoint | Space reused in the block when new rows are inserted (ASSM bitmap updated) |
compact to reclaim disk space | ALTER TABLE ... MOVE or ALTER TABLE ... SHRINK SPACE |
| Data files do not shrink automatically | Datafiles do not shrink automatically (unless auto-shrink is configured) |
Journal records logical delete (by _id) | Redo log records physical block changes |
5. Index Cleanup During Delete
Index cleanup is one of the most expensive parts of a delete operation. Unlike updateOne(), where only changed indexed fields trigger index maintenance, a delete must remove all index entries for the document because the document no longer exists.
Example — index cleanup during a delete:
Collection: students
Indexes: { _id: 1 }, { age: 1 }, { name: 1 }, { grade: 1 }
Document to delete: { _id: 1, name: "Ravi", age: 22, grade: "A" }
Index cleanup:
_id index: REMOVE (1 → record) ← always present
age index: REMOVE (22 → _id:1) ← secondary index
name index: REMOVE ("Ravi" → _id:1) ← secondary index
grade index: REMOVE ("A" → _id:1) ← secondary index
Total index operations: 4 removals
Compare with updateOne({ $set: { grade: "A+" } }):
_id index: NO CHANGE
age index: NO CHANGE
name index: NO CHANGE
grade index: REMOVE ("A" → _id:1), INSERT ("A+" → _id:1)
Total index operations: 2 (1 removal + 1 insertion)
Key points:
- More indexes = slower deletes: Each additional index on the collection adds one more B-tree removal per deleted document. If you have 10 indexes and delete 10,000 documents, WiredTiger must perform 100,000 index removals. This is the same principle as in Oracle, where heavily indexed tables have slower DML.
- Multikey indexes: If the document contains an array field with a multikey index, there may be multiple index entries per document (one per array element). All of them must be removed. For example, a document with
tags: ["math", "science", "history"]and a multikey index ontagshas three index entries that must be removed. - Index entries are tombstoned, not immediately removed: Just like the collection record, index entries are marked with a tombstone and physically removed later during eviction or checkpoint.
- Unique indexes: The unique constraint is released as soon as the tombstone is placed. Other operations can immediately insert a document with the same unique key value, even though the old record’s tombstone is still in the B-tree.
6. Replica Set Write Path
Like inserts and updates, all deletes go to the primary. Secondaries replicate by tailing the oplog.
+----------------+ +-------------------+ +-------------------+
| CLIENT | | PRIMARY (mongod) | | SECONDARY (mongod)|
+-------+--------+ +--------+----------+ +--------+----------+
| | |
| deleteOne() | |
+──────────────────────▶ | |
| | |
| 4. Validate filter |
| 5. Take IX lock |
| 6. Find matching document(s) |
| 7. Tombstone the record |
| Remove all index entries |
| Write to WT cache + journal |
| 8. Write oplog entry |
| op: "d", o: { _id: ... } |
| | |
| | oplog tailing |
| + ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ▶|
| | |
| | Replay the delete:
| | find doc by _id
| | tombstone the record
| | remove index entries
| | write to WT cache
| | |
| |◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─+
| | acknowledgment |
| | |
| 9. Write concern | |
| w:1 ── ack immediately | |
| w:majority ── wait | |
| | |
|◀───────────────────────────+ |
| 11. Response to client | |
| | |
Key points for deletes:
- The oplog entry for a delete is very compact — it contains only the
_idof the deleted document. This is much smaller than an insert oplog entry (which contains the full document) or a replacement update oplog entry. - Secondaries find the document by
_idand apply the same delete. If the document does not exist on the secondary (e.g., it was already deleted by a previous operation), the secondary silently skips the operation. This is called idempotent replication. - If a secondary is significantly behind the primary (high replication lag), the deleted document may still be visible on the secondary for some time. This is why
w: "majority"read concern is important for applications that need to ensure a deleted document is no longer visible anywhere.
7. Sharded Cluster Delete Path
In a sharded cluster, the client connects to mongos, which determines which shard(s) own the matching documents.
+----------------+ +-------------------+
| CLIENT | | mongos |
+-------+--------+ +--------+----------+
| |
| deleteOne() |
+──────────────────────▶ |
| |
| Which shard(s)?
| Check the filter for
| the shard key.
| |
| +────────────+────────────+
| | |
| Shard key in Shard key NOT
| the filter? in the filter?
| | |
| v v
| +-------------------+ +-------------------+
| | TARGETED DELETE | | SCATTER-GATHER |
| | Send to 1 shard | | Send to ALL shards|
| +-------------------+ +-------------------+
| | |
| v v
| +-------------------+ +-------------------+
| | Shard A | | Shard A, B, C... |
| | Steps 4-10 | | Steps 4-10 on |
| | run here | | each shard |
| +-------------------+ +-------------------+
| | |
|◀──────────────+─────────────────────────+
| mongos merges deletedCount
| and sends response
Key points:
- Targeted delete: If the filter contains the shard key,
mongossends the delete only to the shard that owns the matching chunk. This is fast and efficient. - Scatter-gather delete: If the filter does not contain the shard key,
mongosmust send the delete to all shards because any shard might have matching documents. Each shard runs the delete independently, andmongossums up thedeletedCountfrom all shards. This is slower and puts more load on the cluster. - deleteOne() with scatter-gather: Even though
deleteOne()deletes only one document,mongosmust still contact all shards (in the worst case) because it does not know which shard has the matching document. Once one shard confirms a deletion,mongoscan stop, but the initial routing is still a scatter-gather. - Each shard is itself a replica set, so Steps 5–10 (lock, tombstone, index cleanup, oplog, write concern) happen on that shard’s primary.
8. deleteOne vs deleteMany vs drop
| Aspect | deleteOne() | deleteMany() | drop() |
|---|---|---|---|
| Documents removed | At most 1 | All matching documents | All documents (entire collection) |
| Internal limit | limit: 1 | limit: 0 (no limit) | N/A (drops the namespace) |
| Indexes | Entries removed for the deleted doc | Entries removed for each deleted doc | All indexes are dropped entirely |
| Collection exists after? | Yes | Yes (even if empty) | No (collection is removed) |
| Oplog entries | 1 entry (op: "d") | 1 entry per document (op: "d") | 1 entry (op: "c", command: drop) |
| Speed for large datasets | Fast (1 doc) | Slow for millions of docs (per-doc overhead) | Very fast (drops files directly) |
| Atomicity | Atomic (single document) | Not atomic across documents | Atomic (collection-level) |
| Disk space reclaimed? | Eventually (eviction / checkpoint) | Eventually (eviction / checkpoint) | Immediately (files deleted) |
| Oracle equivalent | DELETE ... WHERE ... AND ROWNUM = 1 | DELETE FROM ... WHERE ... | TRUNCATE TABLE or DROP TABLE |
When to use drop() instead of deleteMany({}):
- If you want to remove all documents from a collection and the collection has millions of documents,
drop()is dramatically faster because it removes the data files directly instead of deleting documents one by one. - However,
drop()also removes all indexes, validation rules, and the collection itself. You must recreate them if needed. drop()requires thedropCollectionaction, not theremoveaction.- In a sharded cluster,
drop()must be routed throughmongosand drops the collection on all shards.
Atomicity:
deleteOne()is atomic at the document level. No other operation can see a partially deleted document.deleteMany()is not atomic across documents. Other operations may see some documents deleted and others not yet deleted. If you need multi-document atomicity, use a multi-document transaction (MongoDB 4.0+ for replica sets, 4.2+ for sharded clusters).
9. See It Yourself
Like insertOne() and updateOne(), there is no explain() for the delete phase. However, you can use explain() on the find phase (the filter), and you can observe the delete behavior using these tools:
// 1. Run a delete and see the response
var result = db.students.deleteOne({ name: "Ravi" })
printjson(result)
// { acknowledged: true, deletedCount: 1 }
// 2. Delete with no match — no error
var result2 = db.students.deleteOne({ name: "NonExistent" })
printjson(result2)
// { acknowledged: true, deletedCount: 0 }
// 3. Explain the FIND phase of the delete (which indexes are used?)
db.students.explain("executionStats").deleteMany(
{ age: { $lt: 21 } }
)
// Look at winningPlan to see if IXSCAN or COLLSCAN is used for the filter
// 4. Check the oplog entry (replica set)
use local
db.oplog.rs.find({ ns: "myFirstDB.students", op: "d" })
.sort({ ts: -1 }).limit(1)
// You will see: { op: "d", o: { _id: ObjectId("...") } }
// 5. Check current operations while running a large deleteMany
// In another mongosh session:
db.currentOp({ "command.delete": "students" })
// 6. Check write metrics
db.serverStatus().opcounters
// { insert: 1523, query: 4821, update: 345, delete: 128, ... }
// 7. Enable profiling to see slow deletes
db.setProfilingLevel(2)
db.students.deleteMany({ age: { $lt: 21 } })
db.system.profile.find({ op: "remove" }).sort({ ts: -1 }).limit(1)
db.setProfilingLevel(0)
// Note: in the profiler, deletes appear as op: "remove"
// 8. Check disk space after a large delete
db.students.stats()
// Look at "size" (data size) and "storageSize" (allocated space)
// After a large delete, storageSize may be much larger than size
// because tombstones have not been reclaimed yet.
// 9. Reclaim space with compact (maintenance window only!)
db.runCommand({ compact: "students" })
// This rewrites the collection and indexes, removing tombstones.
// The collection is locked during compaction.
// 10. Compare deleteMany vs drop for removing all documents
// Slow: deletes one by one
db.students.deleteMany({})
// Fast: drops the entire collection
db.students.drop()
// Remember: drop() removes indexes and the collection itself!
Key fields in the oplog entry for a delete:
| Field | What It Tells You |
|---|---|
ts | Timestamp of the operation |
op | "d" for delete |
ns | Namespace: myFirstDB.students |
o | Contains only the _id of the deleted document (very compact) |
wall | Wall clock time when the operation occurred |
10. Oracle Terms Comparison
For DBAs coming from Oracle, here is a side-by-side mapping of every delete step:
| MongoDB | Oracle |
|---|---|
Client sends the delete command | Client sends the DELETE SQL statement |
Authorization check (remove action) | Privilege check (DELETE on the table) |
| Parse filter (query shape) | Parse (syntax + semantic check on WHERE) |
| Query planner finds matching docs (IXSCAN / COLLSCAN) | Optimizer chooses access path for WHERE (INDEX RANGE SCAN / FULL TABLE SCAN) |
| Intent-exclusive (IX) lock on collection | Row-level exclusive lock (TX) + table-level row exclusive (RX/SX) |
| Tombstone on B-tree record | Row marked as deleted in data block (ITL entry, row flag) |
| Remove ALL index entries for the document | Remove index entries for all indexed columns of the row |
| Old version retained for MVCC snapshots | Old version retained in undo segment for read consistency |
| Journal (WAL) for crash recovery | Redo log for crash recovery |
Oplog entry (op: "d", only _id) | Redo log record (physical block change) + undo record |
Secondaries replay the delete by _id | Standby applies redo logs (Data Guard Redo Apply) |
| Auto-commit (single-document atomic) | Explicit COMMIT required |
Write concern w: "majority" | Data Guard SYNC transport |
| Space reclaimed during eviction / checkpoint | Space reused in block (ASSM bitmap); HWM does not move |
compact to reclaim disk space | ALTER TABLE ... SHRINK SPACE or ALTER TABLE ... MOVE |
deleteMany({}) — slow for large collections | DELETE FROM table — slow, generates lots of undo and redo |
drop() — fast, removes files directly | TRUNCATE TABLE — fast, resets HWM, minimal redo |
deletedCount | SQL%ROWCOUNT |
| No error when 0 documents match | No error when 0 rows match (SQL%ROWCOUNT = 0) |
| Multi-document transaction (4.0+) | Standard transaction (BEGIN … COMMIT / ROLLBACK) |
explain() on the filter | EXPLAIN PLAN on the WHERE clause |
Profiler shows op: "remove" | AWR / ASH / V$SQL with command_type = 7 (DELETE) |