skip to content

What does upsert: true do in updateOne, and what does $setOnInsert add?

level: middleimportance: must knowfreq 64%

answer

  1. Update if present, create if absent
  2. Where do the new document's fields come from?
  3. Not every filter condition contributes a field
  4. One operator only fires on the create path
  5. Two clients, same filter, no unique index

basics

~20 s

With upsert: true, a matching document is updated normally; if none matches, MongoDB inserts a new document built from the filter's equality conditions plus the update's operators. $setOnInsert supplies fields that apply only on that insert and are ignored when a document matched.

solid answer

~50 s

`db.c.updateOne(filter, update, { upsert: true })` means "update if it exists, otherwise create it". On the insert path the server assembles the new document from two sources: the **equality clauses** of the filter (`{ _id: "x" }` or `{ tenant: 7, day: "2026-01-01" }`) and the fields the update operators would set. Non-equality conditions such as `{ score: { $gt: 5 } }` contribute nothing, which surprises people. `$setOnInsert` exists for values that should be seeded once — `createdAt`, a status default, a schema version — because they must not be overwritten every time the document is touched; when the filter matched an existing document, `$setOnInsert` is a no-op. The result reports `upsertedId` and `upsertedCount` when an insert happened. Concurrent upserts on the same filter can collide, so back the filter fields with a unique index and be prepared to retry a duplicate-key error.

code

javascript · 7 lines
javascript
db.counters.updateOne(
  { _id: "orders" },
  { $inc: { seq: 1 }, $setOnInsert: { createdAt: new Date() } },
  { upsert: true }
)
// empty collection -> inserts { _id: "orders", seq: 1, createdAt: ... }
// existing document -> increments seq, leaves createdAt untouched

go deeper

for a junior

Know that upsert: true updates an existing document or creates one when none matches, and that $setOnInsert fields apply only on creation.

for a middle

Explain how the inserted document is assembled from the filter's equality clauses plus the update operators, why non-equality conditions contribute nothing, and how the result reports an insert.

for a senior

Demonstrate the concurrency story: why concurrent upserts can duplicate, the unique index plus duplicate-key retry pattern, and how a badly chosen filter makes an upsert insert on every run.

for a principal

Own the identity design — which fields define a record's identity, whether that identity is indexed uniquely everywhere it is upserted, and how the create-once semantics interact with downstream events.

## What upsert means An upsert is a conditional write: update the document the filter matches, and if there is none, insert one. It is requested as an option, not as a separate command — `updateOne`, `updateMany`, `replaceOne` and the `findOneAndUpdate` family all take `{ upsert: true }`, and so do the individual operations inside `bulkWrite`. The value of doing this server-side is that the check and the write are one operation on one document, so two clients racing to create the same logical record do not both succeed in creating it — one of them will find the other's document, or hit a duplicate-key error if you have indexed the identity properly. Doing the same thing in application code (query, branch, insert) leaves a window between the query and the insert. ## How the inserted document is built When no document matches, the server constructs the new one: 1. Start from the **equality clauses** of the filter. `{ _id: "orders" }` contributes `_id: "orders"`; `{ tenant: 7, day: "2026-01-01" }` contributes both fields; a dotted equality such as `{ "key.tenant": 7 }` contributes the nested field. 2. Apply the update's operator expressions to that base — `$set`, `$inc`, `$push`, `$setOnInsert` and so on. 3. If no `_id` came from the filter or the update, the server generates an ObjectId. What is **not** carried over: any condition that is not a plain equality. `{ score: { $gt: 5 } }`, `{ tags: { $in: [...] } }`, `$or` branches, `$expr` — none of these contribute a field, because there is no single value they imply. So an upsert whose filter is entirely non-equality can insert a document that does not itself satisfy that filter, which means the very next run of the same upsert inserts another one. That is a real production bug pattern, and the fix is to make the identity part of the filter a set of equality conditions. An operator worth remembering here is `$inc` on a field that does not exist: it creates the field set to the increment amount. So `updateOne({ _id: "orders" }, { $inc: { seq: 1 } }, { upsert: true })` on an empty collection inserts `{ _id: "orders", seq: 1 }` — the classic sequence-counter idiom. ## $setOnInsert `$setOnInsert` assigns fields **only** when the operation results in an insert. If the filter matched an existing document, the operator does nothing at all — it does not overwrite, and it does not count as a modification. ```js db.counters.updateOne( { _id: "orders" }, { $inc: { seq: 1 }, $setOnInsert: { createdAt: new Date(), schemaVersion: 2 } }, { upsert: true } ) ``` This is how you express "stamp the creation time once" in a call that may run thousands of times against the same document. Using `$set` for `createdAt` there would reset it on every increment. Note that a field may not appear in both `$set` and `$setOnInsert` in the same update — the paths would conflict and the server rejects the write. ## Reading the result The update result distinguishes the two outcomes: `matchedCount` (and `modifiedCount`) describe the update path, while `upsertedCount` and `upsertedId` are populated only when an insert happened. That is how the application learns whether it created the record — useful for emitting a "created" event exactly once, or for logging first-seen entities. ## Concurrency and the unique index An upsert is atomic with respect to the single document it touches, but two concurrent upserts with the same filter can both find no match and both attempt an insert. Without a unique index on the identifying fields, that produces two documents where you wanted one, and the duplication is silent. With a unique index, one insert wins and the other fails with a duplicate-key error; the documented handling is to retry the operation, which then takes the update path because the document now exists. So the pattern is: identity fields in the filter as equality conditions, a unique index over exactly those fields, and a retry on duplicate key. ## replaceOne and updateMany with upsert `replaceOne(filter, replacement, { upsert: true })` inserts the replacement document, again augmented by the filter's equality fields. `updateMany(..., { upsert: true })` updates all matches, but when there are none it inserts exactly **one** document, not several. ## Where upserts bite Beyond the non-equality filter trap, two things are worth flagging. First, an upsert whose filter includes fields the update also modifies can loop: if the filter selects on a status that the update changes, the next run matches nothing and inserts again. Second, the positional `$` operator cannot be used with an upsert that reaches the insert path, because there is no matched array element in a document that did not exist.

  • An upsert filter is {score: {$gt: 5}} and nothing matches. What does the inserted document contain?
    Only the fields the update's operators set, plus a generated `_id`. `$gt` is not an equality clause, so no `score` field is carried into the new document — which means the inserted document does not satisfy the original filter, and running the same upsert again inserts yet another one. Identity fields in an upsert filter must be equality conditions.
  • Why can two concurrent upserts with the same filter still create duplicates, and what prevents it?
    Both operations can find no matching document before either has inserted, so both proceed to insert. A unique index on exactly the filter's identifying fields makes the second insert fail with a duplicate-key error instead of creating a second document; the application retries, and the retry takes the update path. Index plus retry is the documented pattern.
  • How does the update result tell you whether an upsert inserted or updated?
    An insert populates `upsertedId` and sets `upsertedCount` to 1, while `matchedCount` is 0. An update leaves `upsertedCount` at 0 and reports `matchedCount: 1`, with `modifiedCount` telling you whether any value actually changed. Applications use that distinction to fire a "created" side effect exactly once.

saying these in an interview costs you the question

  • Thinks every filter condition becomes a field in the inserted document
  • Uses $set for createdAt in a repeated upsert
  • Assumes upsert alone prevents duplicate documents under concurrency
  • Believes updateMany with upsert inserts one document per unmatched condition
  • Claims $setOnInsert overwrites the field when the document already exists

context