Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 057 / 100

Upserts explained: a MongoDB & NoSQL guide for developers

Insert or update in one operation.

Expona Admin7 min read

Why upserts matter in production systems

One of the most common patterns in production databases is the need to update a record if it exists, or create it if it doesn't. Without upserts, we'd either run a query to check for existence, then conditionally insert or update, or we'd write application logic that tries both operations and handles failures. Upserts explained: a MongoDB & NoSQL guide for developers is our guide to understanding this single-operation alternative that keeps your code clean and your database efficient.

Whether you're syncing external data, maintaining user state across platforms, or tracking inventory from multiple sources, the upsert is a pattern you'll reach for repeatedly. This article walks through how MongoDB's upsert feature works, when to use it, and the gotchas that catch teams in production.

updateOne with upsert: true

MongoDB's updateOne() method accepts an options object with an upsert property. When set to true, MongoDB will update the matching document if it exists, or insert a new document if no match is found. The query filter becomes the basis for both the search and the insert.

Here's the basic pattern:

javascript
const { MongoClient } = require('mongodb');

const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('production');
const users = db.collection('users');

// Upsert: update if email exists, insert if not
const result = await users.updateOne(
  { email: 'alice@example.com' },
  { $set: { lastLogin: new Date() } },
  { upsert: true }
);

console.log(result.upsertedId); // non-null if insert happened
console.log(result.modifiedCount); // 1 if update happened

The filter { email: 'alice@example.com' } is critical. MongoDB uses this to find the document. If no document matches, MongoDB inserts a new one with the filter fields plus the update fields. If a document matches, MongoDB applies the update operators (like $set) to it.

This pattern is invaluable when syncing data from third-party APIs or webhooks. You don't need to query first to check existence—MongoDB handles both cases in one round trip.

Use $setOnInsert for creation-only fields

A common mistake is not realizing that the filter fields are included in an insert. If your filter is { externalId: '12345' } but you want to set an initial createdAt timestamp only on first insert, you need to distinguish between "update these fields every time" and "set these fields only on insert."

That's where $setOnInsert comes in. It's an update operator that applies only if the upsert results in an insert, and is ignored on updates.

javascript
const result = await users.updateOne(
  { externalId: '12345' },
  {
    $set: { 
      name: 'Alice', 
      lastSyncedAt: new Date() 
    },
    $setOnInsert: { 
      createdAt: new Date(),
      status: 'pending'
    }
  },
  { upsert: true }
);

Now, every time you upsert this user:

  • name and lastSyncedAt are updated (or set, if new).
  • createdAt and status are set only if the document is inserted for the first time.

This is essential for accurate audit trails and for preserving initial state that shouldn't be overwritten by subsequent syncs.

Unique indexes prevent duplicates under concurrency

Here's where many teams stumble in production: upserts are atomic at the document level, but if two requests arrive concurrently with the same filter, you can end up with two inserts. MongoDB doesn't automatically prevent this unless you add a unique index on the filter field.

Consider this scenario without a unique index:

  • Request A checks for { email: 'bob@example.com' }. No match found.
  • Request B checks for { email: 'bob@example.com' }. No match found.
  • Request A inserts a new document with that email.
  • Request B inserts another new document with the same email.

Now you have duplicate emails. The upsert didn't prevent this because each request executed atomically, but they were unaware of each other's intent.

The fix is a unique index:

javascript
// Run this once during setup or in a migration
await users.createIndex({ email: 1 }, { unique: true });

Now, if two concurrent upserts try to insert with the same email, one will succeed and the other will fail with a duplicate key error (code 11000). Your application can handle this gracefully—often by retrying or treating it as a non-fatal conflict.

Unique indexes are particularly important when upserting on external IDs, partner email addresses, or any field that must be globally unique in your domain:

javascript
// Sync third-party users by their provider ID
await users.createIndex({ providerId: 1, provider: 1 }, { unique: true });

await users.updateOne(
  { providerId: '67890', provider: 'google' },
  {
    $set: { email: user.email, googleProfile: user.profile },
    $setOnInsert: { createdAt: new Date() }
  },
  { upsert: true }
);

A compound unique index on providerId and provider lets you safely sync users from multiple OAuth providers without fear of duplicates.

Great for syncing external data

Real-world example: you're building a SaaS that pulls customer data from Stripe every hour. Customers can change their email, subscription status, or metadata. You need to reflect these changes in your database, but you also can't lose any local state you've added (like internal notes or billing overrides).

Upserts are the pattern for this:

javascript
async function syncCustomerFromStripe(stripeCustomer) {
  const customers = db.collection('customers');
  
  await customers.updateOne(
    { stripeId: stripeCustomer.id },
    {
      $set: {
        email: stripeCustomer.email,
        name: stripeCustomer.name,
        metadata: stripeCustomer.metadata,
        lastSyncedAt: new Date()
      },
      $setOnInsert: {
        createdAt: new Date(),
        status: 'active',
        internalNotes: ''
      }
    },
    { upsert: true }
  );
}

// Sync all customers from Stripe in a batch
const stripeCustomers = await stripe.customers.list({ limit: 100 });
for (const customer of stripeCustomers.data) {
  await syncCustomerFromStripe(customer);
}

Key benefits here:

  • No wasted queries checking if a customer exists.
  • New customers (returned from Stripe) are automatically created.
  • Existing customers are updated with the latest Stripe data.
  • Local fields like internalNotes are never overwritten.
  • The unique index on stripeId prevents accidental duplicates if syncs overlap.

This pattern also works for inventory syncing from suppliers, user data from identity providers, or lead data from CRM webhooks.

Common mistakes

Forgetting the unique index on the filter field: Your application code will work fine until you hit concurrent traffic. Always create a unique index on the field(s) you use as your upsert filter if that field must be unique in your domain.

Not using $setOnInsert for audit fields: If you use a bare $set for everything, your createdAt or createdBy fields will be overwritten on subsequent syncs. Use $setOnInsert to protect these fields.

Misunderstanding what gets inserted: If your filter is { userId: 123 }, and you upsert with { $set: { email: 'user@example.com' } }, the inserted document will include both userId: 123 and email: 'user@example.com'. The filter fields are always part of the insert. Plan accordingly.

Not handling the 11000 duplicate key error: Even with a unique index, concurrent inserts can race. Catch the error, inspect the error code, and decide whether to retry, ignore, or escalate. Many teams treat a duplicate key on an intentional upsert as non-fatal.

Using upsert for non-idempotent operations: Upserts should be idempotent—running them twice with the same input should produce the same result. If you're using $inc or $push in your upsert, be careful. Running the same upsert twice will increment or append twice. Use $set for state, not incremental operators, unless you truly want idempotent increments.

Key takeaways

  • Upserts explained: a MongoDB & NoSQL guide for developers boils down to one method: updateOne(..., { upsert: true }).
  • Use the filter to find or create, and $set for updates, $setOnInsert for creation-only fields.
  • Always add a unique index on the filter field if uniqueness matters in your domain. This prevents duplicate inserts under concurrency.
  • Upserts are ideal for syncing external data—no pre-check queries needed, and you keep local state intact with $setOnInsert.
  • Catch and handle the 11000 duplicate key error gracefully; it's a sign your unique index is working.
  • Remember that the filter fields are included in inserts. Design your filters with this in mind.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles