Skip to main content
SEMastery
Data Accessintermediate

Optimizing Bulk Database Updates in .NET: A Practical Guide

Learn how to make bulk database updates fast in .NET using EF Core ExecuteUpdate, batching, and transactions, with simple examples and EF Core 10 tips.

12 min readUpdated November 21, 2025

Think about a school with 2000 students. The principal decides that every student in Class 10 should get 5 bonus marks because an exam question was wrong.

There are two ways the office can do this.

The slow way: a clerk pulls out each student's record card, one by one, finds the marks, adds 5, writes the new number, and files the card back. For hundreds of cards, this takes the whole afternoon.

The fast way: the clerk opens the marks register and writes one instruction at the top: "Add 5 marks to every Class 10 student." One line. The whole class is updated together.

Your database works the same way. In .NET, the slow way loads every row into your program's memory, changes it, and saves it back. The fast way sends a single instruction to the database and lets the database do all the heavy lifting. This article shows you how to pick the fast way safely, so your bulk updates finish in milliseconds instead of minutes.

Why the slow way is slow

When you use the classic Entity Framework Core (EF Core) pattern, you usually do three things: load the rows, change them in C#, then call SaveChanges. This feels natural, and for a handful of rows it is perfectly fine.

The problem starts when the number of rows grows. Each row has to travel across the network into your application's memory. EF Core then keeps a copy of every row in something called the change tracker so it can notice what you changed. When you call SaveChanges, EF Core sends an UPDATE statement for each changed row.

EF Core is smart and groups many of these statements into a single round trip (this is called batching), which already helps a lot. But the database still has to run one UPDATE per row. For 10,000 rows, that is 10,000 small updates plus the cost of loading all that data first.

Here is the slow path drawn out.

The classic load-change-save path. Every row travels to memory and back.

Two things make this expensive. First, you move a lot of data you do not even need. Second, you keep thousands of objects alive in memory just to change one column. For a background job that runs often, this can slow down your whole server.

The fast way: ExecuteUpdate

EF Core 7 introduced two methods that change everything for bulk work: ExecuteUpdate and ExecuteDelete. There are also async versions, ExecuteUpdateAsync and ExecuteDeleteAsync, which you should prefer in web apps.

The idea is simple. Instead of loading rows, you describe which rows to change and how, and EF Core turns that into one SQL UPDATE statement. No rows come into memory. The change tracker is never touched. The database does the work in one shot.

Here is the same "add 5 marks" task written the fast way.

// Add 5 bonus marks to every Class 10 student.
// One SQL UPDATE statement. Nothing is loaded into memory.
await dbContext.Students
    .Where(s => s.ClassName == "Class 10")
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(s => s.Marks, s => s.Marks + 5));

Read it like a sentence: "For students where the class is Class 10, set their marks to the current marks plus 5." EF Core sends roughly this SQL to the database:

UPDATE [Students]
SET [Marks] = [Marks] + 5
WHERE [ClassName] = N'Class 10';

That is it. One trip to the database. The database engine, which is built to handle set-based work like this, updates every matching row at once.

This is the fast path.

ExecuteUpdate sends one instruction. No rows are loaded into memory.

You can set more than one column at a time by chaining SetProperty.

// Mark old draft orders as expired and clear their reserved stock,
// all in a single round trip to the database.
int affected = await dbContext.Orders
    .Where(o => o.Status == "Draft" && o.CreatedOn < cutoffDate)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(o => o.Status, "Expired")
        .SetProperty(o => o.ReservedStock, 0));
 
logger.LogInformation("Expired {Count} old draft orders", affected);

Notice that ExecuteUpdateAsync returns the number of rows that were changed. That count is handy for logging and for checking that your job did what you expected.

How much faster is it really?

The difference is not small. It is dramatic. Community benchmarks comparing the two approaches on 10,000 rows show numbers like these.

ApproachWhat it doesRough time for 10,000 rows
Load + SaveChangesLoads rows, tracks them, sends one UPDATE per rowTens of seconds
ExecuteUpdateSends one SQL UPDATE statementTens of milliseconds

In one widely shared benchmark, ExecuteUpdate finished about 500 times faster than the load-and-save approach for 10,000 rows. Your exact numbers will depend on your database, network, and row size, but the pattern holds: the more rows you touch, the bigger the win.

The reason is easy to understand once you see the trade-offs side by side.

FeatureLoad + SaveChangesExecuteUpdate
Loads rows into memoryYesNo
Uses the change trackerYesNo
Fires interceptors and eventsYesNo
Updates entities already in memoryYesNo
Best forA few rows, complex logicMany rows, same change

The fast method gives up some features to gain speed. That is the deal you are making, and it is a good deal for true bulk work. Just make sure you understand what you are giving up, which we cover next.

What ExecuteUpdate gives up

Speed always has a cost. ExecuteUpdate is fast because it skips a lot of EF Core's helpful machinery. Knowing exactly what it skips keeps you out of trouble.

  • It ignores the change tracker. If you already loaded a student into memory and then run ExecuteUpdate, your in-memory copy still shows the old marks. You must reload it to see the new value.
  • It does not fire interceptors or SaveChanges events. If you rely on EF Core interceptors to set "last modified" timestamps or to write an audit log, those will not run. You have to set such columns yourself inside the SetProperty calls.
  • It does not do automatic concurrency checks. EF Core's optimistic concurrency (the row version check) does not apply, because no entity is being tracked.
  • It runs immediately. Unlike SaveChanges, which waits until you call it, ExecuteUpdate runs the moment you call it. There is no "stage now, save later".

Choosing your update method

Few rows?
Complex per-row logic?
Same change for many rows?
Use ExecuteUpdate

Steps

1

Few rows?

Use SaveChanges, it is simplest

2

Complex per-row logic?

Load and SaveChanges fits better

3

Same change for many rows?

ExecuteUpdate is the right tool

4

Use ExecuteUpdate

One statement, very fast

A quick decision path before you write the code.

None of these are reasons to avoid ExecuteUpdate. They are simply things to handle on purpose. For example, if you need an audit timestamp, just set it in the same call.

// Set the LastModified column yourself, since interceptors will not run.
await dbContext.Products
    .Where(p => p.CategoryId == discountedCategoryId)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.Price, p => p.Price * 0.9m)   // 10% off
        .SetProperty(p => p.LastModified, DateTime.UtcNow));

Keeping things safe with transactions

A single ExecuteUpdate call is already wrapped in its own transaction by EF Core. If it fails halfway, the database rolls it back, so you never get a half-finished update.

The care is needed when you run several operations that must all succeed or all fail together. For example, you might want to mark invoices as paid and also reduce a customer's outstanding balance. If the second step fails, you do not want the first one to stay.

Because ExecuteUpdate runs immediately and does not join the SaveChanges transaction by itself, you must open your own transaction to group them.

await using var transaction = await dbContext.Database.BeginTransactionAsync();
try
{
    await dbContext.Invoices
        .Where(i => i.CustomerId == customerId && i.Status == "Due")
        .ExecuteUpdateAsync(s => s.SetProperty(i => i.Status, "Paid"));
 
    await dbContext.Customers
        .Where(c => c.Id == customerId)
        .ExecuteUpdateAsync(s => s.SetProperty(c => c.Balance, 0m));
 
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

Here is the same idea as a flow. Both updates live inside one transaction, so a failure undoes everything.

Two bulk updates grouped in one transaction. A failure rolls both back.

When the change differs per row

ExecuteUpdate shines when every matching row gets the same rule, like "add 5" or "set status to Expired". But sometimes each row needs a different value. Maybe you are importing a price list where each product gets its own new price.

For that, a single ExecuteUpdate does not fit, because the value is different for every row. You have a few good options.

The first option is still EF Core, just with batching. Load the rows, change each one, and call SaveChanges once. EF Core batches the updates into round trips for you. This is fine for a few thousand rows.

The second option, for very large or very frequent jobs, is to process the work in chunks so you never hold too much in memory at once.

Chunked update loop

Take next 1000 ids
Run ExecuteUpdate
More left?
Done

Steps

1

Take next 1000 ids

Keep memory small

2

Run ExecuteUpdate

One statement per chunk

3

More left?

Loop until empty

4

Done

All rows updated

Process big jobs in small, safe batches.

A chunked loop keeps each step small and predictable, which is gentle on both your app and the database.

const int batchSize = 1000;
int updated;
 
do
{
    // Update only the next batch of rows that still need it.
    updated = await dbContext.Products
        .Where(p => p.NeedsReindex)
        .OrderBy(p => p.Id)
        .Take(batchSize)
        .ExecuteUpdateAsync(s => s.SetProperty(p => p.NeedsReindex, false));
}
while (updated == batchSize); // stop when a partial batch means we are done

This loop keeps running while it fills a whole batch, and stops the moment a batch comes back smaller than the limit, because that means there is nothing left.

What is new in EF Core 10

EF Core 10, which runs on the .NET 10 long-term support release, makes ExecuteUpdate even friendlier.

  • Navigation properties can now be used inside ExecuteUpdate. This means you can reference related entities in your update without writing manual joins, which used to be awkward.
  • JSON columns can be updated in bulk more efficiently, which helps a lot if you store flexible data as JSON inside a relational table.
  • The API in general accepts more natural expressions, so the C# you write reads closer to plain English.

If you are still on an older version, the basic ExecuteUpdate pattern works all the way back to EF Core 7, so you can adopt it now and enjoy the extra polish when you upgrade.

A quick word on libraries. You may have heard of paid bulk packages like Entity Framework Extensions or EFCore.BulkExtensions. They are genuinely useful for huge inserts and complex bulk merges. But for plain updates and deletes, the built-in ExecuteUpdate and ExecuteDelete are free, fast, and need no extra dependency. Reach for a paid library only after you measure a real need. (Separately, note that some popular .NET libraries such as MediatR and MassTransit have moved to commercial licensing; that does not affect EF Core's bulk methods, which ship in the box.)

A simple mental model

When you face a "change many rows" task, ask yourself one question: is every row getting the same kind of change?

If yes, use ExecuteUpdate. One statement, no memory load, very fast.

If each row needs a different computed value, load and SaveChanges, and chunk the work if it is large.

If several updates must succeed or fail together, wrap them in a transaction.

Keep this picture in your head and most bulk update decisions become easy.

Quick recap

  • The slow way loads every row into memory, tracks it, and sends one UPDATE per row. It is fine for a few rows but painful for thousands.
  • ExecuteUpdate and ExecuteUpdateAsync send a single SQL UPDATE statement to the database. No rows are loaded, and the change tracker is skipped.
  • For large sets, ExecuteUpdate can be hundreds of times faster than the classic load-and-save approach.
  • The speed comes from skipping features: no change tracking, no interceptors, no automatic concurrency checks, and it runs immediately.
  • Set audit columns like LastModified yourself inside SetProperty, since interceptors will not fire.
  • A single ExecuteUpdate is its own transaction. Group several operations in BeginTransactionAsync when they must all-or-nothing.
  • When each row needs a different value, batch with SaveChanges or process the work in chunks.
  • EF Core 10 on .NET 10 adds navigation property support and better JSON bulk updates.
  • Start with built-in methods. Only add a paid bulk library after you measure a real need.

References and further reading

Related Posts