← All posts
October 3, 2026· 10 min read

MongoDB bulkWrite() Explained: Bulk Updates, Upsert & More

MongoDB bulkWrite() Explained

As a fresher backend developer, bulkWrite() was something new to me.

Previously, whenever I had to fetch and update a large amount of data, I would usually put the logic inside a loop. I never really thought about how many database queries my code was actually making until someone introduced me to MongoDB's bulkWrite().

For example, imagine we have 1,000 users and we need to update their data.

A simple approach might be:

for (const user of users) {
  const existingUser = await UserModel.findOne({
    email: user.email,
  });

  if (existingUser) {
    await UserModel.updateOne(
      { email: user.email },
      {
        $set: {
          is_active: user.isActive,
        },
      }
    );
  }
}

If we have 1,000 users, we could end up doing:

1,000 fetch queries
+
1,000 update queries
--------------------
2,000 database operations

This is where bulkWrite() becomes interesting.

Instead of executing every write separately, we can prepare all the operations and send them as a bulk operation.

const operations = users.map((user) => ({
  updateOne: {
    filter: {
      email: user.email,
    },
    update: {
      $set: {
        is_active: user.isActive,
      },
    },
  },
}));

await UserModel.bulkWrite(operations);

Now, instead of writing our database update inside a loop, we prepare the operations first and then give them to bulkWrite().

That was one of those things that made me realize that writing backend code is not only about making the code work. We also need to think about how our code communicates with the database.


What is bulkWrite()?

In simple words:

bulkWrite() allows us to send multiple write operations together.

For example:

const operations = [
  {
    updateOne: {
      filter: {
        email: "raj@example.com",
      },
      update: {
        $set: {
          is_active: true,
        },
      },
    },
  },
  {
    updateOne: {
      filter: {
        email: "john@example.com",
      },
      update: {
        $set: {
          is_active: false,
        },
      },
    },
  },
];

await UserModel.bulkWrite(operations);

The important thing to understand is that this:

operations.push(...)

does not execute a database query.

We are only preparing the instructions.

The actual database operation happens when we call:

await UserModel.bulkWrite(operations);

MongoDB then processes those operations.


How Does bulkWrite() Work?

I like to think about it like this:

Create operations
       ↓
   operations[]
       ↓
   bulkWrite()
       ↓
     MongoDB
       ↓
Execute the operations

So if we have 1,000 users:

const operations = users.map((user) => ({
  updateOne: {
    filter: {
      email: user.email,
    },
    update: {
      $set: {
        is_active: user.isActive,
      },
    },
  },
}));

await UserModel.bulkWrite(operations);

We first create 1,000 instructions and then pass them to bulkWrite().


What Can We Do With bulkWrite()?

bulkWrite() is not only for updating.

We can use operations such as:

insertOne
updateOne
updateMany
deleteOne
deleteMany
replaceOne

For example:

const operations = [
  {
    insertOne: {
      document: {
        name: "Raj",
      },
    },
  },

  {
    updateOne: {
      filter: {
        email: "john@example.com",
      },
      update: {
        $set: {
          is_active: true,
        },
      },
    },
  },

  {
    deleteOne: {
      filter: {
        email: "old@example.com",
      },
    },
  },
];

await UserModel.bulkWrite(operations);

So one bulkWrite() can contain different types of write operations.


Understanding updateOne

Let's focus on updateOne, because this is where things become more interesting.

A basic updateOne looks like this:

{
  updateOne: {
    filter: {},
    update: {},
  },
}

For example:

{
  updateOne: {
    filter: {
      email: "raj@example.com",
    },

    update: {
      $set: {
        name: "Raj",
      },
    },
  },
}

There are two important parts:

updateOne
   │
   ├── filter
   │
   └── update

What is filter?

The filter tells MongoDB which document we want to update.

For example:

filter: {
  email: "raj@example.com",
}

MongoDB looks for a document matching:

email = raj@example.com

So if we have:

{
  "name": "Raj",
  "email": "raj@example.com"
}

this filter can identify that document.

This is why the filter is very important.

If we are updating one specific user, we should normally use something that uniquely identifies that user, such as:

filter: {
  _id: user._id,
}

or:

filter: {
  email: user.email,
}

If multiple documents can have the same value, then we need to be careful about using it as our filter.

For updateOne, MongoDB updates the first document that matches the filter.


What is $set?

$set is used when we want to create or update a field.

For example:

update: {
  $set: {
    name: "Raj Poudel",
  },
}

Suppose our document is:

{
  "name": "Raj",
  "email": "raj@example.com",
  "age": 25
}

After $set:

{
  "name": "Raj Poudel",
  "email": "raj@example.com",
  "age": 25
}

Only the name was changed.

The other fields are still there.

So $set is useful when we want to update only specific fields instead of replacing the entire document.


What is $unset?

$unset is basically used when we want to remove a field from a document.

For example:

$unset: {
  phone: "",
}

Suppose we have:

{
  "name": "Raj",
  "phone": "9800000000",
  "email": "raj@example.com"
}

After $unset:

{
  "name": "Raj",
  "email": "raj@example.com"
}

The phone field is removed.

This is different from:

$set: {
  phone: null,
}

That would result in:

{
  "name": "Raj",
  "phone": null,
  "email": "raj@example.com"
}

So:

$set: { phone: null }
        ↓
phone still exists

$unset: { phone: "" }
        ↓
phone is removed

What is upsert?

This is another feature that I found very useful.

By default:

upsert: false

If MongoDB doesn't find a document matching the filter, it simply doesn't insert a new one.

For example:

{
  updateOne: {
    filter: {
      email: "raj@example.com",
    },

    update: {
      $set: {
        name: "Raj",
      },
    },

    upsert: false,
  },
}

If the user doesn't exist:

Find user
   ↓
Not found
   ↓
Nothing is inserted

But if we use:

upsert: true

the behavior changes.

Find user
    ↓
 ┌───┴────┐
Found    Not Found
  ↓          ↓
Update     Insert

So:

upsert: true means update the document if it exists, otherwise create it.

MongoDB's default for upsert is false.


upsert: false vs upsert: true

upsert: false

{
  updateOne: {
    filter: {
      email: "raj@example.com",
    },

    update: {
      $set: {
        name: "Raj",
      },
    },

    upsert: false,
  },
}

If the user exists:

UPDATE

If the user doesn't exist:

DO NOTHING

upsert: true

{
  updateOne: {
    filter: {
      email: "raj@example.com",
    },

    update: {
      $set: {
        name: "Raj",
      },
    },

    upsert: true,
  },
}

If the user exists:

UPDATE

If the user doesn't exist:

INSERT

What is $setOnInsert?

Now we have another useful operator:

$setOnInsert

The name itself gives us a good hint.

It means:

Set these fields only when a new document is inserted.

It is mainly useful with:

upsert: true

For example:

{
  updateOne: {
    filter: {
      email: "raj@example.com",
    },

    update: {
      $set: {
        name: "Raj",
      },

      $setOnInsert: {
        created_at: new Date(),
      },
    },

    upsert: true,
  },
}

There are two situations.

If the document already exists

$set
   ↓
Applied

$setOnInsert
   ↓
Ignored

If the document does not exist

$set
   ↓
Applied

$setOnInsert
   ↓
Applied

MongoDB documents this behavior specifically: $setOnInsert only applies when an upsert actually results in a new document being inserted.


Why Would We Use $setOnInsert?

Imagine we have:

{
  "email": "raj@example.com",
  "name": "Raj",
  "created_at": "2026-01-01"
}

Later, we run another synchronization.

We want to update:

name
updated_at

But we don't want to change:

created_at

because that is the original creation time.

So we can write:

update: {
  $set: {
    name: "Raj Poudel",
    updated_at: new Date(),
  },

  $setOnInsert: {
    created_at: new Date(),
  },
}

This gives us a simple pattern:

$set
   ↓
Apply when updating or inserting

$setOnInsert
   ↓
Apply only when inserting

bulkWrite() and Failure Handling

Another thing we need to understand is what happens when one operation fails.

By default, bulkWrite() is ordered.

That means MongoDB processes the operations in order and stops when an error occurs.

For example:

Operation 1 → SUCCESS
Operation 2 → SUCCESS
Operation 3 → ERROR
Operation 4 → NOT PROCESSED
Operation 5 → NOT PROCESSED

This is:

await UserModel.bulkWrite(operations);

because ordered is true by default.


ordered: true

We can also explicitly write:

await UserModel.bulkWrite(operations, {
  ordered: true,
});

This means:

1 → execute
2 → execute
3 → error
4 → stop
5 → stop

This can be useful when the order of operations matters or when we don't want MongoDB to continue after an error.


ordered: false

We can also use:

await UserModel.bulkWrite(operations, {
  ordered: false,
});

Now MongoDB does not need to stop processing the remaining independent operations just because one operation failed.

Conceptually:

Operation 1 → SUCCESS
Operation 2 → SUCCESS
Operation 3 → ERROR
Operation 4 → SUCCESS
Operation 5 → SUCCESS

So the simple difference is:

ordered: true
    ↓
Error occurs
    ↓
Stop processing remaining operations


ordered: false
    ↓
Error occurs
    ↓
Continue processing other operations

One important thing to remember is that ordered: false does not mean "ignore all errors." The failed operation is still a failure; it just allows other operations to continue.


Does bulkWrite() Return the Updated Documents?

This was another thing I found interesting.

When I first learned about bulkWrite(), I expected something like:

Give me all the updated users

But that's not what bulkWrite() is designed to return.

It returns a write result, not all the modified documents.

For example, the result can contain information such as:

insertedCount
matchedCount
modifiedCount
deletedCount
upsertedCount
upsertedIds

MongoDB's bulk write result reports counts and information about upserted IDs rather than returning every updated document.

So if we do:

const result = await UserModel.bulkWrite(operations);

we can inspect things like:

result.matchedCount
result.modifiedCount
result.upsertedCount

But we should not expect:

result.users

containing all the updated MongoDB documents.

If we actually need the updated documents, we may need a separate query after the bulk operation.

This is one of the trade-offs I noticed with bulkWrite():

It is great when our main goal is efficiently performing many write operations, but it is not meant to return all the resulting documents.


One Complete Example

Putting the things we discussed together:

const operations = users.map((user) => ({
  updateOne: {
    filter: {
      email: user.email,
    },

    update: {
      $set: {
        name: user.name,
        is_active: user.isActive,
        updated_at: new Date(),
      },

      $setOnInsert: {
        created_at: new Date(),
      },
    },

    upsert: true,
  },
}));

const result = await UserModel.bulkWrite(operations, {
  ordered: false,
});

Let's read this from top to bottom:

users
  ↓
Create one update operation for each user
  ↓
filter
  ↓
Find the user
  ↓
$set
  ↓
Update normal fields
  ↓
$setOnInsert
  ↓
Set creation fields only for new users
  ↓
upsert: true
  ↓
Create user if it doesn't exist
  ↓
ordered: false
  ↓
Continue processing other operations if one fails
  ↓
bulkWrite()
  ↓
Execute the batch

Final Takeaway

For me, the biggest lesson from bulkWrite() was not just learning another MongoDB method.

It made me start thinking about:

How many times am I talking to the database?

Instead of blindly doing:

for (...) {
  await databaseOperation();
}

we should sometimes ask ourselves:

Can these operations be prepared first
and executed as a bulk operation?

bulkWrite() gives us that option.

The basic mental model I keep in mind is:

bulkWrite()
    ↓
Multiple write operations

updateOne
    ↓
Update one matching document

filter
    ↓
Which document?

$set
    ↓
What should be updated?

$unset
    ↓
What field should be removed?

upsert: true
    ↓
If not found → create it

$setOnInsert
    ↓
Only apply these fields when creating

ordered: true
    ↓
Stop when an error occurs

ordered: false
    ↓
Continue processing other operations

bulkWrite result
    ↓
Counts + upsert information
not all updated documents

For a fresher backend developer like me, understanding this changed the way I look at database operations. It is not only about whether the code works, but also about how efficiently the code interacts with the database.