upsert

An upsert (a portmanteau of “update” and “insert”) is a single MongoDB write operation that updates a document if one matches your filter, or inserts a brand-new document if none does. Instead of writing a findOne check followed by a conditional insertOne or updateOne — two round trips with a race condition in between — you set { upsert: true } and let the server decide atomically. This is one of the most useful patterns in MongoDB for “create-or-update” workflows: user preference records, page-view counters, caching external API results, or syncing data from another system.

Overview / How it works

Every update method in MongoDB (updateOne, updateMany, findOneAndUpdate, and the update operations inside bulkWrite) accepts an upsert option in its options object. By default upsert is false: if the filter matches zero documents, nothing happens and the operation reports matchedCount: 0, modifiedCount: 0. Set upsert: true and that same “no match” case instead triggers an insert.

The critical thing to understand is what gets inserted. MongoDB does not just insert an empty document and then apply your update — it builds the new document by merging two things: (1) the equality conditions from your filter (fields compared with a plain value or with $eq), and (2) the update document you supplied, including any $setOnInsert fields. Fields in the filter that use non-equality operators ($gt, $in, $or, etc.) are not copied into the inserted document, because there’s no single value MongoDB could pick. If the update uses an update document (with $set, $inc, and so on) those operators are applied against the synthesized starting document. If you pass a full replacement document instead of an update-operator document, that replacement document is inserted as-is (its own fields override any filter-derived fields with the same name).

Because the whole matched-or-inserted decision and the write happen as one atomic operation on the server, upserts are safe to use even when multiple clients might race to create the “same” document — as long as the field you’re matching on is covered by a unique index. Without a unique index, two concurrent upserts that both see “no match” can both attempt an insert, and MongoDB will only let one succeed; the other throws a duplicate key error (E11000) that your application needs to handle, typically by retrying the update.

If no _id is supplied (directly or via the filter’s equality conditions), MongoDB auto-generates an ObjectId for the newly inserted document, exactly as with a normal insertOne.

Syntax

db.collection.updateOne(
  <filter>,
  <update>,
  { upsert: true }
);
  • filter — query document selecting the document to update. Its equality fields (e.g. { sku: "ABC123" }) seed the inserted document if no match is found.
  • update — either an update-operator document ($set, $inc, $setOnInsert, $push, …) or a full replacement document.
  • upsert — boolean option, default false. Set true to insert when no document matches.

updateMany takes the same shape, but note: even with upsert: true, at most one document is ever inserted, because “no documents matched” is a single fact, not one per potential match. findOneAndUpdate also accepts upsert, plus a returnDocument option ("before" or "after") so you can get the resulting document back in the same call.

Examples

Example 1: Basic upsert with updateOne

Suppose you track a per-user settings document and want to set a theme preference — creating the settings document the first time a user visits.

db.userSettings.updateOne(
  { userId: "u_4471" },
  { $set: { theme: "dark" } },
  { upsert: true }
);

Output:

{
  acknowledged: true,
  insertedId: ObjectId("66b1f2a3c9d4e5f6a7b8c9d0"),
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1
}

No document matched { userId: "u_4471" }, so MongoDB inserted a new document combining the filter’s equality field with the $set update: { _id: ObjectId(...), userId: "u_4471", theme: "dark" }. Running the exact same command again now finds the document, so it updates it in place instead: matchedCount: 1, modifiedCount: 0, upsertedCount: 0 (0 modified because theme is already "dark").

Example 2: Separating insert-only fields with $setOnInsert

Often you want some fields set only on the very first insert (like a createdAt timestamp) and others updated every time. Use $setOnInsert alongside $set.

db.pageViews.updateOne(
  { path: "/blog/mongodb-upsert" },
  {
    $inc: { views: 1 },
    $set: { lastViewedAt: new Date() },
    $setOnInsert: { createdAt: new Date() }
  },
  { upsert: true }
);

Output (first run, document did not exist):

{
  acknowledged: true,
  insertedId: ObjectId("66b1f31bc9d4e5f6a7b8c9d1"),
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1
}
// Resulting document:
// {
//   _id: ObjectId("66b1f31bc9d4e5f6a7b8c9d1"),
//   path: "/blog/mongodb-upsert",
//   views: 1,
//   lastViewedAt: ISODate("2026-08-03T10:15:00.000Z"),
//   createdAt: ISODate("2026-08-03T10:15:00.000Z")
// }

On every subsequent visit, views increments and lastViewedAt refreshes, but $setOnInsert is skipped entirely because the document already exists — createdAt stays fixed at the first view. This is the standard pattern for “create-or-touch” counters.

Example 3: findOneAndUpdate upsert returning the document

When you need the resulting document back immediately (e.g. to send it in an API response), use findOneAndUpdate instead of updateOne.

db.inventory.findOneAndUpdate(
  { sku: "WIDGET-42" },
  { $inc: { quantity: 10 }, $setOnInsert: { warehouse: "east" } },
  { upsert: true, returnDocument: "after" }
);

Output:

{
  _id: ObjectId("66b1f3c1c9d4e5f6a7b8c9d2"),
  sku: "WIDGET-42",
  quantity: 10,
  warehouse: "east"
}

returnDocument: "after" returns the post-write state (the default in the shell is "before", which would return null here since the document didn’t previously exist). This single call replaces a slower “read, decide, write” sequence and avoids the window where another request could interleave.

How it works step by step

When the server receives an update with upsert: true, it runs roughly this sequence:

  1. Use the filter to look for a matching document, via an index if one covers the filter, otherwise a collection scan.
  2. If a match is found: apply the update document (or replacement) to it, exactly like a normal update, and return matchedCount/modifiedCount.
  3. If no match is found: synthesize a starting document from the filter’s equality conditions (fields compared by direct value or $eq; operators like $gt or $in are dropped), then apply the update document’s operators ($set, $inc, $setOnInsert, etc.) on top of it, or use the replacement document directly.
  4. Assign an _id (auto-generated ObjectId if not already present) and insert the resulting document. This insert-after-no-match step happens atomically with the match check, so no other write can sneak in between them for the same document.
  5. If a unique index rejects the insert because another concurrent request just created a matching document, the driver surfaces an E11000 duplicate key error instead of silently succeeding.

Common Mistakes

Mistake 1: Expecting updateMany + upsert to insert multiple documents

// Wrong assumption: this inserts one document per missing sku
db.inventory.updateMany(
  { sku: { $in: ["A1", "A2", "A3"] } },
  { $set: { restocked: true } },
  { upsert: true }
);

If none of A1, A2, A3 exist, this inserts exactly one new document (and since the filter uses $in, not a plain equality, MongoDB can’t even pick which sku to copy in — you’d get a document with no sku field at all). For multiple independent create-or-update operations, loop with individual updateOne upserts or use bulkWrite:

await db.inventory.bulkWrite(
  ["A1", "A2", "A3"].map((sku) => ({
    updateOne: {
      filter: { sku },
      update: { $set: { restocked: true } },
      upsert: true
    }
  }))
);

Mistake 2: No unique index, so concurrent upserts create duplicates

If two requests both check for { userId: "u_4471" } at nearly the same instant and neither finds it yet, both can independently succeed in inserting — unless a unique index on userId forces the second one to fail with a duplicate key error instead of silently creating a second settings document.

db.userSettings.createIndex({ userId: 1 }, { unique: true });

Always back the field(s) you upsert on with a unique index when duplicates would be a real bug, and have your application retry (as a plain update, no longer an upsert) on E11000.

Mistake 3: Overwriting fields you meant to preserve

Using a full replacement document as the update, instead of $set, wipes out every other field on an existing match — not just on insert:

// Wrong: replaces the ENTIRE existing document, losing createdAt, email, etc.
db.userSettings.updateOne(
  { userId: "u_4471" },
  { theme: "dark" },
  { upsert: true }
);

Use an update-operator document ({ $set: { theme: "dark" } }) unless you genuinely intend a full replace.

Best Practices

  • Put a unique index on whatever field(s) you filter by when upserting, so concurrent writers can’t create duplicates.
  • Use $setOnInsert for fields that should only be set the first time (creation timestamps, default flags), and $set/$inc for fields that should update every time.
  • Prefer equality filters ({ sku: "ABC" }) over range or $in filters when upserting — only equality fields get copied into the inserted document, and range/`$in` filters make the “what gets inserted” behavior confusing.
  • Use findOneAndUpdate with returnDocument: "after" when your application needs the resulting document immediately, instead of an upsert followed by a separate findOne.
  • Handle E11000 duplicate key errors in application code as a retry-as-update path, rather than assuming an upsert can never fail.
  • For batches of independent create-or-update operations, use bulkWrite with an array of updateOne upserts rather than looping individual round trips.

Practice Exercises

  • Write an upsert on a db.carts collection keyed by { sessionId } that adds an item to an items array with $push on match, and sets createdAt only on insert. Check the returned upsertedCount on both the first and second run.
  • Given a db.leaderboard collection with documents { playerId, highScore }, write a single upsert that only ever raises highScore to a new value and never lowers it (hint: research the $max update operator).
  • Create a unique index on db.inventory‘s sku field, then simulate a race by running the same upsert twice in a row and explain, in your own words, why the second run is a plain update rather than a second insert.

Summary

  • An upsert updates a matching document, or inserts a new one if no document matches — atomically, in one server round trip.
  • Set it via the upsert: true option on updateOne, updateMany, findOneAndUpdate, or inside a bulkWrite operation.
  • The inserted document is built from the filter’s equality conditions plus the update document’s operators; non-equality filter fields ($gt, $in, …) are not copied in.
  • $setOnInsert sets fields only when a new document is created, leaving them untouched on ordinary updates.
  • updateMany with upsert: true still inserts at most one document, never one per potential match.
  • Back upsert filter fields with a unique index to prevent duplicate inserts from concurrent requests, and handle the resulting E11000 error in your application.