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.

Intermediate90–120 minutesMutation semantics + failure-aware labMongoDB 8.3.8 · mongosh 2.10.0 · PyMongo 4.17Last reviewed: September 2026

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.

01

Use findOneAndUpdate(), findOneAndReplace(), and findOneAndDelete() as compound single-document operations.

02

Control which matching document is selected with a deterministic sort.

03

Distinguish default “before” return semantics from returnDocument:"after" / ReturnDocument.AFTER.

04

Use projection on the returned document without confusing projection with persisted-state mutation.

05

Explain what the atomic compound operation guarantees and what it does not guarantee across documents or external side effects.

Reproducible lab baseline · reviewed 2 September 2026

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.

Deliberately wrong approach

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

bash · start the disposable MongoDB 8.3.8 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})' 
javascript · before/after return semantics and deterministic selection
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()});
python · PyMongo ReturnDocument.AFTER claim
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

  1. Why is findOneAndUpdate better than findOne then updateOne for claiming one queue item?
  2. What does returnDocument:"after" change?
  3. Why include _id in the sort after priority?
  4. Does projection remove fields from the stored document?
  5. 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.

bash · cleanup/reset
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

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.