findOneAndUpdate and findOneAndDelete

MongoDB’s findOneAndUpdate() and findOneAndDelete() combine a query, a write, and a document read into a single atomic operation. Where a plain updateOne() only tells you how many documents matched and modified, these two commands hand back the actual document — either as it looked before the change or after it — so you never need a second query to see what you just did. That matters most when you need to act on a document exactly once: claiming the next job in a queue, archiving a record as you delete it, or checking a condition and updating in the same instant so no other request can sneak in between. This lesson covers both methods end to end: syntax, options, internals, and the mistakes that trip people up.

Overview: How findOneAndUpdate and findOneAndDelete Work

Every write against a single document in MongoDB is already atomic — the storage engine guarantees that one document’s fields are read and written as an indivisible unit, even under concurrent load. updateOne() and deleteOne() rely on this too, but they only report a summary such as { acknowledged: true, matchedCount: 1, modifiedCount: 1 }. If you also need the document itself — its previous values, or its values right after your change — the naive approach is to run a find() and then a separate updateOne() or deleteOne(). That two-step approach is not atomic as a whole: another client can modify or delete the same document in the gap between your read and your write. This is a classic read-modify-write race condition, and it is easy to hit in anything that looks like a job queue, a counter, or an inventory check.

findOneAndUpdate() and findOneAndDelete() close that gap. Internally, both compile down to the same low-level server command, findAndModify, which locates a single matching document, applies the update or removal, and returns a document version — all as one operation the server executes without another writer being able to interleave against that same document. You get the atomicity of updateOne()/deleteOne() plus the read you would otherwise need a second round trip for.

The trade-off is scope: both methods touch at most one document, matching the first document that satisfies the filter (optionally ordered with a sort when more than one document could match). If you need to affect many documents, reach for updateMany() or deleteMany() instead — these two methods exist specifically for the case where you need exactly one document, atomically, with its data returned.

One default surprises almost everyone the first time: findOneAndUpdate() returns the document as it looked before your update was applied, unless you explicitly ask for the post-update version. findOneAndDelete() has no such ambiguity — since the document no longer exists afterward, it always returns the pre-deletion document, or null if nothing matched the filter.

Syntax

findOneAndUpdate() takes a filter, an update document, and an options object:

db.collection.findOneAndUpdate(
  filter,
  update,
  options
);
Parameter Type Description
filter document Query used to select the candidate document, same syntax as find().
update document Update operators ($set, $inc, etc.) or a full replacement document.
options.projection document Which fields to include or exclude in the returned document.
options.sort document Orders the candidates when the filter could match more than one document, so you control which single document is affected.
options.upsert boolean If true, inserts a new document when no document matches the filter.
options.returnDocument string "before" (default) or "after" — which version of the document to return.
options.maxTimeMS number Aborts the operation server-side if it runs longer than this many milliseconds.

findOneAndDelete() takes just a filter and options — there is no update document and no returnDocument option, since the returned document is always the pre-deletion state:

db.collection.findOneAndDelete(
  filter,
  options
);

Its options support projection, sort, and maxTimeMS, with the same meaning as above.

Examples

Example 1: Updating a user and getting the new document back

Suppose db.users stores account documents and you want to mark a user active and stamp their last login, then immediately use the updated values (say, to send back in an API response):

db.users.findOneAndUpdate(
  { username: "sam92" },
  { $set: { status: "active", lastLogin: new Date() } },
  { returnDocument: "after" }
);

Output:

{
  _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
  username: "sam92",
  status: "active",
  lastLogin: ISODate("2026-08-03T10:15:00.000Z")
}

Because returnDocument: "after" was passed, the call returns the document with status and lastLogin already reflecting the $set. Leave that option out and you would get back the document exactly as it looked before the update — still showing the old status.

Example 2: Atomically claiming the next job in a queue

This is the pattern where findOneAndUpdate() earns its keep over a plain find() + updateOne(). Multiple worker processes are pulling from db.jobs, and only one worker should ever claim a given pending job:

db.jobs.findOneAndUpdate(
  { status: "pending" },
  { $set: { status: "processing", workerId: "worker-7", startedAt: new Date() } },
  { sort: { priority: -1, createdAt: 1 }, returnDocument: "after" }
);

Output:

{
  _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2"),
  status: "processing",
  priority: 5,
  createdAt: ISODate("2026-08-03T09:00:00.000Z"),
  workerId: "worker-7",
  startedAt: ISODate("2026-08-03T10:20:00.000Z")
}

The sort picks the highest-priority, oldest pending job among the candidates, and the read-and-write happens as one server-side step. Even if ten workers call this at the same instant, each one that matches gets a different document back, because the server serializes access to each document as it processes the command — no two workers can walk away with the same job.

Example 3: Deleting and archiving an order in one step

When you delete something you often also want to log or archive what was deleted. findOneAndDelete() gives you the removed document without a second query:

db.orders.findOneAndDelete(
  { _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0f3"), status: "completed" }
);

Output:

{
  _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0f3"),
  customer: "Priya Nair",
  status: "completed",
  total: 249.5,
  completedAt: ISODate("2026-07-30T18:42:11.000Z")
}

The filter guards against deleting an order that isn’t actually completed. The returned document is the exact order that was removed, which you could now insert into an orders_archive collection or write to an audit log — all without a race between reading the order and deleting it.

How It Works Step by Step

When you call findOneAndUpdate() or findOneAndDelete(), mongosh sends a single findAndModify command to the server, not two separate commands. The server then: (1) uses the query planner to locate a matching document, using an index if one covers the filter (and the sort, if given) — exactly as it would for find(), so an unindexed filter still means a full collection scan, just for one operation instead of two; (2) if multiple documents match, applies sort to pick exactly one; (3) applies the update operators or removes the document as part of that same step, with the storage engine guaranteeing no other write can interleave against that specific document in between; (4) captures either the before-image, the after-image, or (for deletes) the removed document, depending on your options; and (5) returns that single document, or null if nothing matched. Because steps 1–4 happen inside one server-side operation, there is no window where another client could see or modify the document between your check and your write — the guarantee a two-query approach cannot offer.

Common Mistakes

Mistake 1: Assuming you get the updated document back by default.

const updated = db.users.findOneAndUpdate(
  { username: "sam92" },
  { $inc: { loginCount: 1 } }
);
print(updated.loginCount);
// prints the OLD count - returnDocument defaults to "before"

Without returnDocument: "after", updated holds the document exactly as it looked before the $inc ran, so updated.loginCount is one behind the real value. Fix it by asking for the post-update version explicitly:

const updated = db.users.findOneAndUpdate(
  { username: "sam92" },
  { $inc: { loginCount: 1 } },
  { returnDocument: "after" }
);
print(updated.loginCount);
// now prints the count AFTER the increment

Mistake 2: Using find() plus updateOne() where you actually need atomicity.

const job = db.jobs.findOne({ status: "pending" });
db.jobs.updateOne({ _id: job._id }, { $set: { status: "processing" } });
// two workers running this at the same time can both read
// the same job before either update lands - both process it

This looks safe on a quiet system, but under concurrency two workers can both run the findOne() before either updateOne() executes, so both believe they claimed the same job. Collapse the read and the write into one atomic call instead:

const job = db.jobs.findOneAndUpdate(
  { status: "pending" },
  { $set: { status: "processing" } },
  { sort: { createdAt: 1 } }
);
// the read and the write happen as one atomic server-side step,
// so only one worker can ever claim a given job

Best Practices

  • Reach for findOneAndUpdate()/findOneAndDelete() whenever you need a check-and-act step to be atomic — not just when you happen to want the document back.
  • Always pass returnDocument: "after" explicitly when your code depends on the post-update values, rather than relying on the (surprising) default.
  • Add a sort whenever your filter could match more than one document, so the affected document is deterministic instead of whichever one the storage engine happens to scan first.
  • Make sure an index covers the filter (and sort) fields, and confirm with explain() that MongoDB is using an IXSCAN, not a COLLSCAN — atomicity doesn’t help if the server has to scan the whole collection to find your one document.
  • Use projection to return only the fields you actually need, especially on large documents.
  • Don’t reach for these methods when you actually need to affect many documents — use updateMany()/deleteMany(), which don’t return a document at all.
  • When upserting with findOneAndUpdate(), use $setOnInsert for fields that should only be set on the insert branch, so they aren’t clobbered on ordinary updates.

Practice Exercises

  • Write a findOneAndUpdate() on an inventory collection that decrements a product’s stock by a requested quantity, but only if enough stock is available (hint: filter on stock being greater than or equal to the requested amount), returning the document after the update.
  • Write a findOneAndDelete() that removes the oldest document in a logs collection where archived: true, sorted by createdAt ascending.
  • Predict, then verify in mongosh: what does findOneAndUpdate() return when the filter matches zero documents and no upsert option is given? Confirm your answer against the actual returned value.

Summary

  • findOneAndUpdate() and findOneAndDelete() read and modify a single document as one atomic server-side operation, closing the race-condition gap that a separate find() plus updateOne()/deleteOne() leaves open.
  • Both compile down to the same findAndModify server command.
  • findOneAndUpdate() defaults to returning the document before the update — pass returnDocument: "after" to get the updated version.
  • findOneAndDelete() always returns the pre-deletion document, or null if nothing matched.
  • Use sort to make the affected document deterministic when the filter could match more than one.
  • These methods only ever touch one document — use updateMany()/deleteMany() for bulk operations.
  • An index covering the filter and sort still matters; atomicity doesn’t avoid a collection scan.