Chapter 03 · CRUD Fundamentals: Insert, Find, Projection, Sort, Limit, and Delete
DeleteOne/DeleteMany, Precise Predicates, Safety Checks, and Observable Change Counts
Make deletion a reviewed state transition: preview exact scope, use precise predicates, inspect acknowledged/deleted counts, and prove the postcondition.
Learning outcomes
AtlasMart must purge expired import-staging records without touching current products. Deletes are easy to type and hard to undo. The correct workflow is therefore preview the exact predicate → verify scope → delete → inspect acknowledged/deleted counts → query the same predicate again. The destructive command is only one step in the evidence chain.
Explain deleteOne versus deleteMany and their acknowledged/deletedCount result metadata.
Use unique or otherwise precise predicates when the business intent is one specific document.
Build preflight count/sample checks before deleteMany and verify the postcondition afterward.
Diagnose the danger of empty or broad filters and of assuming deleteOne chooses a deterministic match.
Understand topology caveats: multi-shard deleteMany and concurrent chunk migrations are separate concerns from this standalone lab.
Mandatory labs use a disposable loopback-only standalone
mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim
with a dedicated AtlasMart collection and explicit reset
commands. Driver examples pin pymongo==4.17.0.
The standalone is intentionally unauthenticated only for these
short-lived local exercises; do not publish it beyond
127.0.0.1. Docker commands use syntax accepted
directly by ordinary shells; if the first cleanup reports that
the container does not exist, that is harmless.
Retryable-write behavior that requires a replica set or
sharded cluster is explained but not claimed for this
standalone.
Docker, mongod, mongosh, and PyMongo are not available in this generation environment. Commands were checked against current official MongoDB Server and PyMongo documentation, but product commands were not executed here. Expected-output blocks describe stable fields and relationships to verify; they are not fabricated captured transcripts.
1. deleteOne removes at most one match; it does not express which arbitrary match you intended
deleteOne(filter, options) removes one matching
document and returns {acknowledged, deletedCount}.
If the filter matches nothing, deletedCount is
zero. If the filter can match several documents, using
deleteOne is usually a modeling/API smell because
the application has not identified the target precisely. Prefer
a unique key such as _id or another uniquely
indexed business identifier when the intent is one specific
record.
const target = db.crud_products.findOne({_id:"retired-001"}, {_id:1,sku:1,status:1});printjson({before:target});const result = db.crud_products.deleteOne({_id:"retired-001"});printjson(result);printjson({after:db.crud_products.findOne({_id:"retired-001"})});
deletedCount:1 plus a subsequent null lookup proves
this document is absent after the operation in the observed
state. It does not prove an external system cannot recreate it
later or that a replica/backup retention requirement is
satisfied.
2. deleteMany should begin as a read-only preflight
deleteMany(filter) removes all matching documents.
Before running it, execute the same filter through
countDocuments() and a bounded sorted sample. That
turns a destructive intention into a reviewable scope.
const filter = {source:"import-2026-09-02", status:"staging", expiresAt:{$lt:ISODate("2026-09-02T00:00:00Z")}};const count = db.crud_products.countDocuments(filter);const sample = db.crud_products.find(filter, {_id:1,sku:1,expiresAt:1}).sort({_id:1}).limit(10).toArray();printjson({filter, count, sample});if (count > 0 && count <= 100) { const result = db.crud_products.deleteMany(filter); printjson(result); printjson({remaining:db.crud_products.countDocuments(filter)});} else { print("ABORT: preflight count outside expected lab range");}
The numerical guard is a lab-specific safety belt, not a production universal threshold. Real systems should derive thresholds from expected batch size, approval policy, maintenance windows, tenant scope, and recovery capability.
3. Deliberately wrong: empty and under-scoped predicates
deleteMany({}) matches every document in a
collection. It does not drop the collection or its indexes, but
it still destroys all records. Another dangerous filter is
syntactically valid but missing a tenant or batch boundary, for
example {status:"staging"} when the intention was
only one import job.
// DO NOT run against valuable data.const dangerous = {status:"staging"};const intended = {tenantId:"tenant-demo", source:"import-2026-09-02", status:"staging"};printjson({dangerousCount:db.crud_products.countDocuments(dangerous)});printjson({intendedCount:db.crud_products.countDocuments(intended)});printjson(db.crud_products.find(dangerous,{_id:1,tenantId:1,source:1,status:1}).sort({_id:1}).limit(20).toArray());
The repair is not merely “be careful.” Encode tenant/import identity in the filter, log the exact predicate, compare expected versus actual count, require approval for unusual scope, and maintain tested backups/restore procedures. Destructive correctness is a workflow property.
4. Driver results and unacknowledged-write limits
PyMongo returns a DeleteResult. With acknowledged
writes you can inspect deleted_count and
raw_result. If a write is explicitly
unacknowledged, the driver cannot truthfully provide those
counts; accessing result properties other than
acknowledged is invalid. This is why
safety-critical deletion should normally use acknowledged write
concern.
from pymongo import MongoClientclient = MongoClient("mongodb://127.0.0.1:27030/?directConnection=true", serverSelectionTimeoutMS=3000)coll = client.atlasmart.crud_productsflt = {"tenantId":"tenant-demo", "source":"import-2026-09-02", "status":"staging"}print("preflight", coll.count_documents(flt))result = coll.delete_many(flt)print("acknowledged", result.acknowledged)print("deleted_count", result.deleted_count)print("remaining", coll.count_documents(flt))client.close()
In sharded deployments, deleteMany() has additional
routing and migration caveats. Current MongoDB documentation
warns that concurrent chunk migrations can cause a
multi-document delete to miss matching documents, with
recommended mitigation such as iterative deletion until the same
query returns none or using a transaction where appropriate.
That behavior is not reproduced by this standalone lesson; it
belongs to later sharding/transaction chapters.
5. AtlasMart safe-delete lab
docker rm -f atlasmart-mongo-ch03-l4docker run --name atlasmart-mongo-ch03-l4 -p 127.0.0.1:27030:27017 -d mongodb/mongodb-community-server:8.3.8-ubuntu2204-slimdocker logs atlasmart-mongo-ch03-l4 --tail 25
mongosh "mongodb://127.0.0.1:27030/atlasmart?directConnection=true" --quiet --eval 'db.crud_products.drop();db.crud_products.insertMany([ {_id:"live-1",tenantId:"tenant-demo",source:"catalog",status:"active"}, {_id:"stage-1",tenantId:"tenant-demo",source:"import-2026-09-02",status:"staging"}, {_id:"stage-2",tenantId:"tenant-demo",source:"import-2026-09-02",status:"staging"}, {_id:"other-stage",tenantId:"tenant-other",source:"import-2026-09-02",status:"staging"}]);const filter={tenantId:"tenant-demo",source:"import-2026-09-02",status:"staging"};printjson({beforeCount:db.crud_products.countDocuments(filter), sample:db.crud_products.find(filter).sort({_id:1}).toArray()});const r=db.crud_products.deleteMany(filter);printjson(r);printjson({remainingSameFilter:db.crud_products.countDocuments(filter), survivors:db.crud_products.find({}, {_id:1,tenantId:1,status:1}).sort({_id:1}).toArray()});'
Verification checklist
- The preflight finds exactly the two intended tenant-demo staging records.
-
deletedCountequals the preflight count in this quiescent standalone fixture. - A postcondition query using the identical filter returns zero.
- The active document and the other tenant's staging record remain.
- You can explain why this equality is not automatically guaranteed in every concurrent sharded scenario.
Check your understanding
- Why is {_id:value} a safer deleteOne filter than {status:"retired"}?
- What should happen before deleteMany?
- What does deletedCount:0 mean?
- Why is deleteMany({}) dangerous even though it keeps indexes?
- Why are sharded deleteMany caveats deferred from the lab?
Review the answers
_id is unique, so the filter identifies the intended document rather than an arbitrary member of a larger matching set.
Run the exact filter as a read-only preflight: count it, inspect a bounded deterministic sample, and compare scope with expectations.
No document matching that operation was deleted. It may mean the filter matched none, or the target state changed before execution.
It matches every document, so it removes all collection records. Preserving collection metadata does not make data loss safe.
They require a sharded topology and chunk-migration behavior; the standalone lab cannot honestly demonstrate those conditions.
docker rm -f atlasmart-mongo-ch03-l4
The final lesson combines insert, find, cursor, and delete into one auditable CRUD workflow.
Authoritative references
- MongoDB release notes — Official current stable server series and patch notes.
- MongoDB 8.3 release notes — Official 8.3 patch history; 8.3.8 is the latest released patch at review time.
- MongoDB CRUD operations — Official CRUD overview and server semantics.
- PyMongo CRUD guides — Official Python driver CRUD behavior and result objects.
- deleteOne() — Single-document deletion and result metadata.
- deleteMany() — Multi-document deletion, result metadata, sharding caveats, and transactions.
- PyMongo delete documents — DeleteResult acknowledged/deleted_count behavior.