Upserts
An upsert is “update if exists, insert if not.” updateOne + { upsert: true }. Atomic, race-free, the foundation of counters, idempotency, and cache patterns.
updateOne upsert, replaceOne, findAndModify
EXAMPLE
// 1) Basic upsert
await db.users.updateOne(
{ email: 'ada@example.com' },
{ $set: { name: 'Ada', lastSeen: new Date() } },
{ upsert: true },
);
// If 'ada@example.com' exists → updates. Otherwise → inserts.
// Returns:
// { matchedCount, modifiedCount, upsertedCount, upsertedId }
// 2) Set only on insert — $setOnInsert
await db.users.updateOne(
{ email: 'ada@example.com' },
{
$set: { lastSeen: new Date() }, // every time
$setOnInsert: { createdAt: new Date(), id: crypto.randomUUID() }, // only when inserting
$inc: { visits: 1 },
},
{ upsert: true },
);
// createdAt is set on first call; not touched on subsequent calls.
// 3) Counter pattern — atomic, race-free
await db.counters.updateOne(
{ name: 'orders' },
{ $inc: { value: 1 } },
{ upsert: true },
);
// Get the new value:
const { value } = await db.counters.findOneAndUpdate(
{ name: 'orders' },
{ $inc: { value: 1 } },
{ upsert: true, returnDocument: 'after' },
);
console.log(value.value); // new counter value
// 4) Idempotency — same request many times, single insert
await db.events.updateOne(
{ idempotencyKey: req.headers['idempotency-key'] },
{
$setOnInsert: {
payload: req.body,
createdAt: new Date(),
status: 'received',
},
},
{ upsert: true },
);
// Whether the request is duplicated or not, the document exists exactly once.
// 5) replaceOne — full replace, optionally upsert
await db.users.replaceOne(
{ _id: userId },
{ name: 'Ada', email: 'a@x.com', updatedAt: new Date() },
{ upsert: true },
);
// Replaces ALL fields except _id with the new doc.
// Different from updateOne: replaceOne doesn't preserve fields not in the new doc.
// 6) findOneAndUpdate — atomic read + write
const doc = await db.users.findOneAndUpdate(
{ _id: userId },
{ $set: { lastSeen: new Date() }, $inc: { logins: 1 } },
{ returnDocument: 'after', upsert: true },
);
// Returns the updated (or newly-created) document atomically.
// returnDocument: 'before' returns the pre-update doc; useful for state-machine transitions.
// 7) Concurrent upserts — race-safe with unique index
await db.users.createIndex({ email: 1 }, { unique: true });
// Two concurrent processes both upserting the same email:
await Promise.all([
db.users.updateOne({ email: 'a@x.com' }, { $set: { name: 'Ada' } }, { upsert: true }),
db.users.updateOne({ email: 'a@x.com' }, { $set: { name: 'Cy' } }, { upsert: true }),
]);
// Mongo guarantees one insert + one update. Final state: one doc with name from whichever ran second.
// Without the unique index, you can get duplicate documents under contention.
// 8) Catch duplicate-key on concurrent insert
try {
await db.users.updateOne(
{ email: 'a@x.com' },
{ $setOnInsert: { name: 'Ada' } },
{ upsert: true },
);
} catch (e) {
if (e.code === 11000) {
// Duplicate-key during upsert race — retry once
await db.users.updateOne(
{ email: 'a@x.com' },
{ $set: { name: 'Ada' } }, // existing doc; just update
);
} else throw e;
}
// 9) Bulk upsert
await db.products.bulkWrite([
{
updateOne: {
filter: { sku: 'A-100' },
update: { $set: { stock: 50, price: 9.99 } },
upsert: true,
},
},
{
updateOne: {
filter: { sku: 'A-101' },
update: { $set: { stock: 30, price: 19.99 } },
upsert: true,
},
},
], { ordered: false });
// 10) Upsert with array operations
await db.users.updateOne(
{ email: 'a@x.com' },
{
$setOnInsert: { createdAt: new Date() },
$addToSet: { tags: 'newsletter' },
},
{ upsert: true },
);
// 11) Time-windowed counter (rate limit, daily metrics)
const today = new Date().toISOString().slice(0, 10); // '2026-06-08'
await db.metrics.updateOne(
{ date: today, type: 'pageview' },
{
$inc: { count: 1 },
$setOnInsert: { date: today, type: 'pageview', createdAt: new Date() },
},
{ upsert: true },
);
// 12) Cache-style upsert with TTL
await db.cache.updateOne(
{ key: 'user:42:profile' },
{
$set: { value: JSON.stringify(profile), expiresAt: new Date(Date.now() + 300_000) },
},
{ upsert: true },
);
// Then a TTL index automatically removes expired entries
await db.cache.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
// 13) Upsert + custom _id
await db.events.updateOne(
{ _id: req.headers['idempotency-key'] }, // client-supplied key
{ $setOnInsert: { payload: req.body, createdAt: new Date() } },
{ upsert: true },
);
// 14) When upserts are wrong
// • You need to know if it was insert or update → use upsertedId in result
// • You need different fields per branch → use findOneAndUpdate + check result
// • Workflow requires a CREATE event vs UPDATE event → split into two operations
// • You need transactional consistency across docs → use a session + transaction
// 15) Common bugs
// • Forgetting unique index → duplicate documents under contention
// • Using $set instead of $setOnInsert for fields that should only set once
// • Forgetting findOneAndUpdate's returnDocument: 'after' → returns stale value
// • Race condition without retry on duplicate-key error
// • Upsert on filter that doesn't match → unexpected new docs
// 16) Performance
// • Index the FILTER field — without an index, upsert scans the collection
// • bulk upserts >>> sequential upserts (1 round trip vs N)
// • For high-write workloads, sharded counters across multiple docs reduce contention
// 17) Best practices
// • Pair upserts with a unique index on the filter field
// • Use $setOnInsert for createdAt + generated IDs
// • Use idempotency keys for HTTP request deduplication
// • Catch + retry duplicate-key errors on concurrent upserts
// • findOneAndUpdate when you need the resulting doc atomically
Why it matters
Upsert + unique index is the race-safe insert-or-update pattern. \$setOnInsert for “set once” fields (createdAt, generated IDs); \$set for everything you want to refresh on every call.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
db.counters.updateOne(
{ name: 'hits' },
{ $inc: { value: 1 } },
{ upsert: true }
);
Try it Yourself »
Discussion
Loading…