Upserts & Bulk Writes

MongoDB · Lesson 11 of 28 7 min read Updated

Some writes do not know in advance whether the document exists. "Add one to today's view count for this product" should create the counter the first time and increase it afterwards. "Save this customer's login time" should create a profile for a brand-new user. MongoDB handles this with an upsert: an update that inserts a document when nothing matches.

In this lesson you will write upserts, control which fields are set only on insert, build a counter for order numbers, and send many different writes in one request with bulkWrite(). Start with freshly loaded data and use shop.

Upsert: update or insert

Add { upsert: true } as the third argument of updateOne(), updateMany() or replaceOne(). If a document matches, it is updated as usual. If none matches, MongoDB builds a new document from the filter's equality fields, applies the update to it and inserts it.

The shop counts product page views per day. The first view of the earbuds today creates the counter:

MongoDB Shell
db.productViews.updateOne(
  { productId: 105, day: '2026-10-30' },
  { $inc: { views: 1 } },
  { upsert: true }
)
Output
{
  acknowledged: true,
  insertedId: ObjectId('6ae43a7cc3f5e8a91d2b7a07'),
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1
}

Nothing matched, so upsertedCount is 1 and insertedId holds the _id of the new document. Run the exact same command a second time and the result shows matchedCount: 1, modifiedCount: 1, upsertedCount: 0, because the counter now exists. Look at the document:

MongoDB Shell
db.productViews.findOne({ productId: 105 })
Output
{
  _id: ObjectId('6ae43a7cc3f5e8a91d2b7a07'),
  productId: 105,
  day: '2026-10-30',
  views: 2
}

productId and day came from the filter, and views came from $inc, which treats a missing field as 0. One command handles both the first view and every later one, with no "does it exist?" check in your code.

$setOnInsert: fields for new documents only

Sometimes a field should be written only when the document is created. $setOnInsert does exactly that; on an update of an existing document it is ignored. A customer logs in with an email address: if she is new, create her profile; either way, record the login time:

MongoDB Shell
db.customers.updateOne(
  { email: 'neha@example.com' },
  {
    $set: { lastLogin: ISODate('2026-10-30T08:00:00Z') },
    $setOnInsert: { _id: 9, name: 'Neha Joshi', city: 'Lucknow', joined: ISODate('2026-10-30'), premium: false }
  },
  { upsert: true }
)
Output
{
  acknowledged: true,
  insertedId: 9,
  matchedCount: 0,
  modifiedCount: 0,
  upsertedCount: 1
}

Neha was new, so all the fields were written. If she logs in again tomorrow with a new lastLogin, only that field changes; her joined date and name stay as they were. A field may not appear in both $set and $setOnInsert in the same update.

A counter with findOneAndUpdate

Order numbers such as 1013, 1014, 1015 need a counter that never hands out the same number twice, even under heavy traffic. Keep one document per counter and increase it atomically, asking for the value after the change:

MongoDB Shell
db.counters.findOneAndUpdate(
  { _id: 'orderNo' },
  { $inc: { seq: 1 } },
  { upsert: true, returnDocument: 'after' }
)
Output
{ _id: 'orderNo', seq: 1 }

The first call creates the counter with seq: 1; each later call returns 2, 3 and so on. To continue from the shop's existing orders, start the counter at 1012 once with db.counters.insertOne({ _id: 'orderNo', seq: 1012 }) before the first call. You will use this pattern in Project: Online Shop Backend. Most applications are happy with ObjectIds, so use counters only when people need short sequential numbers, such as invoice numbers.

bulkWrite(): many writes in one request

bulkWrite() sends a list of different write operations on one collection to the server in a single round trip. Each element of the list names its operation: insertOne, updateOne, updateMany, replaceOne, deleteOne or deleteMany. A supplier's weekly price file might produce this batch:

MongoDB Shell
db.products.bulkWrite([
  { insertOne: { document: { _id: 113, name: 'Phone Tripod', category: 'Mobile', price: 699, stock: 40, rating: 4.0, tags: ['mobile', 'video'] } } },
  { updateOne: { filter: { _id: 106 }, update: { $set: { stock: 20 } } } },
  { updateOne: { filter: { _id: 105 }, update: { $set: { price: 2299 } } } },
  { updateOne: {
      filter: { _id: 114 },
      update: { $setOnInsert: { name: 'Laptop Sleeve', category: 'Accessories', price: 799, stock: 35, rating: 4.2, tags: ['laptop'] } },
      upsert: true
  } }
], { ordered: false })
Output
{
  acknowledged: true,
  insertedCount: 1,
  insertedIds: { '0': 113 },
  matchedCount: 2,
  modifiedCount: 2,
  deletedCount: 0,
  upsertedCount: 1,
  upsertedIds: { '3': 114 }
}

The result totals every kind of write. insertedIds and upsertedIds are keyed by the position of the operation in your list: operation 0 inserted product 113 and operation 3 upserted product 114.

As with insertMany(), bulk writes are ordered by default and stop at the first error; { ordered: false } lets the remaining operations run and lets the server execute them in any order. A bulk write is not a transaction: if operation 3 fails, operations 0 to 2 stay done. When several writes must succeed or fail together, use Transactions.

When you have finished, reload the shop data with load('shop-data.js').

Common mistakes

  • Upserting without a unique index. Concurrent upserts can create duplicates. Index the filter fields uniquely.
  • Putting changing values in the filter. { email: e, lastLogin: t } as an upsert filter never matches yesterday's document, so you get a new document every day. Filter only on what identifies the document.
  • Expecting $set values only on insert. $set applies on every call; use $setOnInsert for creation-only fields such as joined.
  • Treating bulkWrite() as all-or-nothing. Earlier operations are not undone when a later one fails.

Key points

  • { upsert: true } updates a matching document or inserts a new one built from the filter and the update.
  • The result's upsertedCount and insertedId tell you when an insert happened.
  • $setOnInsert writes fields only when the upsert creates a document.
  • findOneAndUpdate() with $inc, upsert and returnDocument: 'after' makes a safe sequence counter.
  • bulkWrite() sends mixed inserts, updates, replaces and deletes on one collection in one round trip; it is not a transaction.
  • MongoDB 8.0's bulkWrite command can write to several collections in one request.

Exercise

Customers can save products to a wish list. Write one upsert that adds product 107 to customer 4's document in a wishlists collection (using customerId as the identifying field), creating the wish list with a createdAt date if it does not exist and never storing the same product twice. Run it twice and predict both results.

Show solution
MongoDB Shell
db.wishlists.updateOne(
  { customerId: 4 },
  { $addToSet: { productIds: 107 }, $setOnInsert: { createdAt: ISODate('2026-10-30') } },
  { upsert: true }
)

The first run inserts a new wish list (upsertedCount: 1, insertedId is a new ObjectId) with productIds: [107]. The second run matches it (matchedCount: 1) but $addToSet finds 107 already there, so modifiedCount is 0, and $setOnInsert is ignored. A unique index on customerId would protect against two simultaneous first visits.

Quick Quiz

1. What does { upsert: true } do when no document matches the filter?

2. When are $setOnInsert fields written?

3. An ordered bulkWrite() of four operations fails at operation 2. What is true?

Learn MongoDB with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call