Chapter 05 · Updates, Array Mutations, Upserts, findAndModify, and Bulk Writes
findOneAndUpdate / Replace / Delete: Atomic Read-Modify-Write and Return Semantics
Use MongoDB compound find-and-modify operations to select and mutate one document atomically while controlling sort, projection, before/after return semantics, and failure expectations.
Learning outcomes
AtlasMart workers need to claim exactly one ready job and
immediately receive the state that was actually claimed. A
separate find() followed by
updateOne() allows two workers to select the same
candidate. MongoDB’s find-and-modify family combines selection
and one-document mutation so the returned document corresponds
to the same atomic operation.
Use findOneAndUpdate(), findOneAndReplace(), and findOneAndDelete() as compound single-document operations.
Control which matching document is selected with a deterministic sort.
Distinguish default “before” return semantics from returnDocument:"after" / ReturnDocument.AFTER.
Use projection on the returned document without confusing projection with persisted-state mutation.
Explain what the atomic compound operation guarantees and what it does not guarantee across documents or external side effects.
Mandatory examples use MongoDB Community Server
8.3.8 in the pinned
mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim
image, a disposable standalone mongod published
only on 127.0.0.1:27040, and mongosh 2.10.0.
PyMongo examples target the 4.17 line where driver behavior
matters. Authentication and TLS are intentionally disabled
only inside this isolated loopback lab; do not copy that
posture to a shared or remotely reachable server. Default
standalone read/write concern semantics are used, no
replica-set/sharding guarantees are claimed, and the reset
path removes atlasmart-mongo-ch05-l4. The queue
example is intentionally one-document state. If claiming a job
must atomically reserve inventory in another document or
invoke an external API, the design needs a larger
workflow/transaction/idempotency discussion later in the
course.
1. Compound operations close the read-then-write gap
findOneAndUpdate() finds one document and applies
an update as one server operation.
findOneAndReplace() replaces one selected document.
findOneAndDelete() deletes one and returns the
deleted document. They are useful when the caller needs the
selected document—not merely update/delete counts—and the
selection must be coupled to the mutation.
The sort option matters when several documents
match. A queue that says “highest priority” should encode that
ordering in the operation. Include a unique tie-breaker such as
_id when duplicate priority values are possible so
selection is deterministic.
2. “Before” and “after” are API semantics
In mongosh, findOneAndUpdate() returns the original
document by default. Set returnDocument:"after" to
receive the updated document. PyMongo expresses the same choice
with ReturnDocument.AFTER. This option changes the
value returned to the client; it does not mean the update itself
happened in two phases.
| Operation | Default returned value | After option |
|---|---|---|
| findOneAndUpdate | Matched document before update | Updated document |
| findOneAndReplace | Matched document before replacement | Replacement document as persisted |
| findOneAndDelete | Deleted document | No “after document” exists; deletion returns what was removed |
3. Atomic does not mean “the whole workflow is done”
The claim operation can safely transition one job from
ready to leased and increment its
attempt counter atomically within the selected document. It does
not make sending an email exactly once. A worker can claim,
send, and crash before recording completion. External side
effects require idempotency keys, deduplication, retry policy,
and reconciliation. Similarly, an unindexed or non-unique upsert
can still produce duplicate logical entities under races;
uniqueness remains a schema/index responsibility.
const job=findOne({status:"ready"});
updateOne({_id:job._id},...)
gives another worker a window to read the same ready job.
Combine the predicate and state transition in
findOneAndUpdate(), and include the current state
in the filter so a job already claimed by another worker no
longer qualifies.
4. Run the atomic claim/replace/delete lab
docker rm -f atlasmart-mongo-ch05-l4 2>/dev/null || truedocker run --name atlasmart-mongo-ch05-l4 -p 127.0.0.1:27040:27017 -d mongodb/mongodb-community-server:8.3.8-ubuntu2204-slimmongosh "mongodb://127.0.0.1:27040/atlasmart?directConnection=true" --quiet --eval 'printjson({version:db.version(), hello:db.hello().isWritablePrimary})'
db.job_queue.drop();db.job_queue.insertMany([ {_id:"job-1",kind:"email",status:"ready",priority:10,attempts:0,payload:{to:"a@example.test"}}, {_id:"job-2",kind:"email",status:"ready",priority:20,attempts:0,payload:{to:"b@example.test"}}, {_id:"job-3",kind:"export",status:"ready",priority:5,attempts:0,payload:{format:"csv"}}]);// Default returns the document before the update. sort controls which ready job wins.let before=db.job_queue.findOneAndUpdate( {status:"ready"}, {$set:{status:"leased",worker:"w1"},$inc:{attempts:1}}, {sort:{priority:-1,_id:1},projection:{payload:0}});printjson({step:"findOneAndUpdate-before",returned:before,persisted:db.job_queue.findOne({_id:before._id})});// returnDocument:"after" returns the updated state.let after=db.job_queue.findOneAndUpdate( {status:"ready"}, {$set:{status:"leased",worker:"w2"},$inc:{attempts:1}}, {sort:{priority:-1,_id:1},returnDocument:"after",projection:{payload:0}});printjson({step:"findOneAndUpdate-after",returned:after});// findOneAndReplace returns the original by default unless after is requested.let replaced=db.job_queue.findOneAndReplace( {_id:"job-3"}, {kind:"export",status:"ready",priority:7,attempts:0,payload:{format:"parquet"},schemaVersion:2}, {returnDocument:"after"});printjson({step:"findOneAndReplace",returned:replaced});let deleted=db.job_queue.findOneAndDelete({status:"leased"},{sort:{priority:-1,_id:1},projection:{payload:0}});printjson({step:"findOneAndDelete",deleted,remaining:db.job_queue.find().sort({_id:1}).toArray()});
from pymongo import MongoClient, ReturnDocumentclient=MongoClient("mongodb://127.0.0.1:27040/?directConnection=true")q=client.atlasmart.job_queueclaimed=q.find_one_and_update( {"status":"ready"}, {"$set":{"status":"leased","worker":"python-w"},"$inc":{"attempts":1}}, sort=[("priority",-1),("_id",1)], return_document=ReturnDocument.AFTER, projection={"payload":0},)print(claimed)client.close()
Expected evidence
The first claim returns the original
ready representation while a fresh read shows
leased; the second returns the updated leased state
directly. Replacement returns the complete new export job
representation. Deletion returns the document that was removed.
Selection follows priority:-1,_id:1.
Verification checklist
- The queue contains several matching documents so sort semantics are observable.
- The first result explicitly compares returned-before state with persisted-after state.
- The second request uses returnDocument:"after".
- Projection removes payload only from the returned value, not as a storage mutation.
- The read-then-update race is explained and avoided in the correct pattern.
- External side effects are not described as exactly-once because of one atomic database mutation.
Check your understanding
- Why is findOneAndUpdate better than findOne then updateOne for claiming one queue item?
- What does returnDocument:"after" change?
- Why include _id in the sort after priority?
- Does projection remove fields from the stored document?
- Does an atomic job claim make the external email exactly once?
Review the answers
Selection and mutation happen as one compound server operation, so another caller cannot interpose between the separate read and update steps for that same predicate/state transition.
Only which version of the matched document is returned to the client: the post-update state instead of the original.
It provides a deterministic tie-breaker when multiple jobs have the same priority.
No. Projection limits fields in the returned document. Mutation comes from the update/replacement/delete operation itself.
No. The external side effect needs idempotency/retry/reconciliation because process failure can occur after claim or after send.
docker rm -f atlasmart-mongo-ch05-l4
The chapter ends by widening one call from one mutation to many. Bulk APIs improve batching and expose rich per-operation results, but they are not automatically transactions.
Authoritative references
- MongoDB release notes — Current stable server series and patch history.
- MongoDB 8.3 release notes — 8.3.8 is the latest released 8.3 patch at review time; 8.3.9 is upcoming.
- mongosh release notes — mongosh 2.10.0 was released August 13, 2026.
- PyMongo release notes — Current PyMongo 4.17 line and driver changes.
- findOneAndUpdate() — Filter, sort, projection, update, upsert, and before/after return semantics.
- findOneAndReplace() — Atomic find-and-replace behavior and return options.
- findOneAndDelete() — Selection, sort, projection, deletion, and returned-document semantics.
- PyMongo compound operations — Driver-level find-one-and-update/replace/delete usage and ReturnDocument options.