Type something to search...
Upserts in MongoDB: Insert or Update in a Single Operation

Upserts in MongoDB: Insert or Update in a Single Operation

A lot of application code contains some version of this: look up a record, and if it exists, update it; if it doesn't, create it. It's two round trips, and it has a gap in the middle. Two requests arrive at the same moment, both see "no record", and both insert. Now you have duplicate profiles, double-counted stats, or two carts for one user.

An upsert collapses that logic into a single operation. You describe which document you want and what should change, and MongoDB either updates the match or inserts a new document built from your filter and update. It's one of the most useful features in MongoDB for sync jobs, counters, caches, and idempotent writes, but it has a few subtleties around how the new document is built and how concurrency is handled.

This guide covers the upsert option on each write method, exactly how inserted documents are constructed, $setOnInsert, reading the result, concurrency and unique indexes, bulk upserts, and the patterns and pitfalls you'll run into.

The Basics

Any update or replace method accepts an upsert: true option:

db.profiles.updateOne(
  { userId: 42 },
  { $set: { displayName: "Ada", updatedAt: new Date() } },
  { upsert: true },
);

If a profile with userId: 42 exists, its displayName and updatedAt are set. If not, MongoDB inserts:

{
  _id: ObjectId("..."),
  userId: 42,
  displayName: "Ada",
  updatedAt: ISODate("2026-09-20T07:57:00.000Z")
}

The upsert option works with:

  • updateOne and updateMany
  • replaceOne
  • findOneAndUpdate and findOneAndReplace
  • updateOne, updateMany, and replaceOne operations inside bulkWrite

Note that updateMany with upsert: true inserts at most one document. If nothing matches, you get a single new document built from the filter and update.

How the Inserted Document Is Built

When an upsert inserts, MongoDB builds the new document in two steps:

  1. It takes the equality conditions from the filter and uses them as the starting fields.
  2. It applies the update operators to that starting document.
db.inventory.updateOne(
  { sku: "MUG-01", warehouse: "leeds" },
  { $inc: { qty: 25 }, $set: { lastRestock: new Date() } },
  { upsert: true },
);
// inserted: { _id, sku: "MUG-01", warehouse: "leeds", qty: 25, lastRestock: ... }

$inc on a missing field starts from zero, so the first restock inserts qty: 25, and later restocks add to it. That makes this one call a complete "create or increment" operation.

Non-Equality Conditions Are Dropped

Only conditions that pin a field to a specific value become part of the new document. Range and comparison operators don't:

db.jobs.updateOne(
  { name: "nightly-report", attempts: { $lt: 3 } },
  { $set: { status: "queued" } },
  { upsert: true },
);
// inserted: { _id, name: "nightly-report", status: "queued" }
// no "attempts" field: $lt doesn't define a value

Explicit $eq does count as equality, and so does a dotted path, which creates nested fields:

db.settings.updateOne(
  { "owner.id": 7, "owner.type": "team" },
  { $set: { theme: "dark" } },
  { upsert: true },
);
// inserted: { _id, owner: { id: 7, type: "team" }, theme: "dark" }

Conditions inside $or or $and with a single branch can be used, but complex logical filters are harder to reason about. Keep upsert filters to simple equality on the fields that identify the document.

Where _id Comes From

If the filter includes _id as an equality condition, the new document uses that value. Otherwise the server generates an ObjectId. Using a meaningful _id in the filter (a natural key, as discussed in the post on ObjectId and its alternatives) gives you a guaranteed-unique identity for free:

db.dailyViews.updateOne(
  { _id: { page: "/pricing", day: "2026-09-20" } },
  { $inc: { views: 1 } },
  { upsert: true },
);

Replacement Upserts

With replaceOne, the replacement document becomes the new document, plus _id if the filter specified one. Filter fields are not merged in:

db.cache.replaceOne(
  { key: "weather:leeds" },
  { key: "weather:leeds", value: { tempC: 14 }, fetchedAt: new Date() },
  { upsert: true },
);

If you forget to include key in the replacement, the inserted document won't have it, and the next upsert won't find it. Always include the identifying fields in replacement documents.

$setOnInsert: Fields That Only Apply on Insert

Often you want some fields written only when the document is created, such as a creation timestamp or default settings. $setOnInsert does exactly that; it's ignored when the upsert matches an existing document:

db.users.updateOne(
  { email: "ada@example.com" },
  {
    $set: { lastLoginAt: new Date() },
    $inc: { loginCount: 1 },
    $setOnInsert: {
      createdAt: new Date(),
      plan: "free",
      prefs: { theme: "light", newsletter: false },
    },
  },
  { upsert: true },
);

On first login, the user is created with defaults and loginCount: 1. On later logins, only lastLoginAt and loginCount change, and the user's chosen theme is left alone.

You can't mention the same field in $set and $setOnInsert; that's a path conflict error. If you want "set on insert, but also update later", put it in $set only.

Reading the Result

The Node.js driver's updateOne result tells you what happened:

// inserted
{
  acknowledged: true,
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1,
  upsertedId: ObjectId("66ed2b7f9d1c4a0b7e3f1a22")
}

// updated
{
  acknowledged: true,
  matchedCount: 1,
  modifiedCount: 1,
  upsertedCount: 0,
  upsertedId: null
}

upsertedId is non-null only when a document was inserted, which is a clean way to branch in application code. (In mongosh, the same value is reported as insertedId. PyMongo exposes it as result.upserted_id.)

const res = await users.updateOne(filter, update, { upsert: true });
if (res.upsertedId) {
  await sendWelcomeEmail(email);
}

Upserts with findOneAndUpdate

When you need the resulting document back (for example, to return it from an API), use findOneAndUpdate with returnDocument: "after":

const counter = await db
  .collection("counters")
  .findOneAndUpdate(
    { _id: "invoice" },
    { $inc: { seq: 1 } },
    { upsert: true, returnDocument: "after" },
  );

console.log(counter.seq); // 1 on the first call, then 2, 3, ...

In Node.js driver 6.x, findOneAndUpdate returns the document directly (or null). Pass includeResultMetadata: true if you need the older { value, ok, lastErrorObject } shape, where lastErrorObject.upserted tells you whether an insert happened. With the default returnDocument: "before", an upsert that inserts returns null, since no document existed before.

Concurrency: The Part Everyone Gets Wrong

An upsert is atomic for a single document, but "find the match or insert" is not magically serialized across concurrent operations. If two upserts with the same filter run at the same moment and neither finds a match, both can insert, leaving you with duplicates.

The fix is a unique index on the fields in the filter:

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

With the index in place, the second concurrent insert fails with a duplicate key error (code 11000). MongoDB retries certain upserts internally when it hits a duplicate key error on the exact filter fields covered by the unique index, so in many cases the second operation simply turns into an update. The conditions for that automatic retry are specific (the filter must be an equality match on the unique index's fields), so your code should still be ready to catch E11000 and retry once:

async function upsertProfile(userId, fields) {
  for (let attempt = 0; attempt < 2; attempt++) {
    try {
      return await profiles.updateOne(
        { userId },
        { $set: fields, $setOnInsert: { createdAt: new Date() } },
        { upsert: true },
      );
    } catch (err) {
      if (err.code === 11000 && attempt === 0) continue;
      throw err;
    }
  }
}

The unique index is the real guarantee. Without it, no amount of application logic fully prevents duplicates under load. The guide to duplicate key errors and unique constraints covers E11000 handling in more depth.

Bulk Upserts

Syncing data from another system (a product feed, a CRM export, a nightly API pull) is a perfect job for upserts. Batch them with bulkWrite to avoid one round trip per record:

const ops = products.map((p) => ({
  updateOne: {
    filter: { sku: p.sku },
    update: {
      $set: { name: p.name, price: p.price, syncedAt: new Date() },
      $setOnInsert: { createdAt: new Date() },
    },
    upsert: true,
  },
}));

const result = await db
  .collection("products")
  .bulkWrite(ops, { ordered: false });

console.log(result.upsertedCount, result.modifiedCount);

ordered: false lets the server continue past individual failures and can execute operations in parallel. Pair it with a unique index on sku. For very large feeds, send batches of a few thousand operations at a time so memory use stays predictable.

The same pattern in PyMongo:

from datetime import datetime, timezone
from pymongo import UpdateOne

now = datetime.now(timezone.utc)
ops = [
    UpdateOne(
        {"sku": p["sku"]},
        {"$set": {"name": p["name"], "price": p["price"], "syncedAt": now},
         "$setOnInsert": {"createdAt": now}},
        upsert=True,
    )
    for p in products
]

result = db.products.bulk_write(ops, ordered=False)
print(result.upserted_count, result.modified_count)

A useful side effect of the syncedAt field: after a full sync, anything with an older syncedAt no longer exists upstream, so you can mark or remove it with a single updateMany or deleteMany.

Practical Upsert Patterns

Counters and Aggregates

Page views per day, API calls per key per hour, votes per option: an upsert with $inc and a compound key handles all of these without pre-creating documents:

db.apiUsage.updateOne(
  { apiKey: "k_live_81", hour: new Date("2026-09-20T07:00:00Z") },
  { $inc: { calls: 1, [`byEndpoint.${endpoint}`]: 1 } },
  { upsert: true },
);

Be careful with dynamic field names like byEndpoint.${endpoint}: they must not contain dots or start with $, so sanitize them.

Idempotent Event Processing

If a message queue may deliver the same event twice, upsert on the event's ID so reprocessing is harmless:

db.payments.updateOne(
  { providerEventId: event.id },
  {
    $setOnInsert: {
      amount: event.amount,
      currency: event.currency,
      receivedAt: new Date(),
    },
  },
  { upsert: true },
);

Only $setOnInsert is used, so a duplicate delivery changes nothing. With a unique index on providerEventId, you're protected even against concurrent redeliveries.

Caches with Expiry

Upsert cached values and let a TTL index clean them up:

db.cache.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });

db.cache.updateOne(
  { _id: "rates:GBP" },
  { $set: { value: rates, expiresAt: new Date(Date.now() + 10 * 60 * 1000) } },
  { upsert: true },
);

Upsert or Insert?

Upserts aren't always the right tool. If a duplicate would indicate a bug (a user signing up twice with the same email), a plain insertOne against a unique index is more honest: the duplicate key error surfaces the problem instead of quietly updating an existing account.

SituationUse
Record may or may not exist, both are fineUpsert
Syncing from an external source of truthUpsert (usually in bulk)
Counters, rollups, per-period aggregatesUpsert with $inc
A duplicate means something is wronginsertOne + unique index
The record must already existupdateOne, check matchedCount

Common Pitfalls

Upserting without a unique index. Concurrent upserts with the same filter can each insert a document. A unique index on the filter fields is the only reliable guarantee.

Filtering on fields that aren't identifying. A filter like { email, status: "active" } will insert a second document for the same email once the first becomes inactive. Filter only on the fields that define identity; put everything else in the update.

Expecting range conditions to appear in the new document. Only equality conditions seed the inserted document. If you need a field on insert, set it explicitly with $setOnInsert.

Putting the same field in $set and $setOnInsert. That's a conflict error. Decide whether the field should change on every write or only at creation.

Forgetting identifying fields in replacement upserts. replaceOne doesn't copy filter fields (except _id) into the new document, so include them in the replacement.

Conclusion

An upsert turns "find it, then update or insert" into a single atomic call. The inserted document is built from the filter's equality conditions plus your update operators, $setOnInsert handles creation-only fields, and upsertedId tells you which path was taken. Combine upserts with a unique index on the identifying fields and a single retry on duplicate key errors, and they become a safe foundation for syncs, counters, caches, and idempotent event handling.

Find one place in your code that runs a findOne followed by a conditional insertOne or updateOne. Replace it with a single upsert, add a unique index on the filter fields, and you'll close a race condition while halving the round trips.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading