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.
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.
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.
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.
| Approach | What it does | Rough time for 10,000 rows |
|---|---|---|
Load + SaveChanges | Loads rows, tracks them, sends one UPDATE per row | Tens of seconds |
ExecuteUpdate | Sends one SQL UPDATE statement | Tens 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.
| Feature | Load + SaveChanges | ExecuteUpdate |
|---|---|---|
| Loads rows into memory | Yes | No |
| Uses the change tracker | Yes | No |
| Fires interceptors and events | Yes | No |
| Updates entities already in memory | Yes | No |
| Best for | A few rows, complex logic | Many 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
SaveChangesevents. 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 theSetPropertycalls. - 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,ExecuteUpdateruns the moment you call it. There is no "stage now, save later".
Choosing your update method
Steps
Few rows?
Use SaveChanges, it is simplest
Complex per-row logic?
Load and SaveChanges fits better
Same change for many rows?
ExecuteUpdate is the right tool
Use ExecuteUpdate
One statement, very fast
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.
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
Steps
Take next 1000 ids
Keep memory small
Run ExecuteUpdate
One statement per chunk
More left?
Loop until empty
Done
All rows updated
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 doneThis 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
UPDATEper row. It is fine for a few rows but painful for thousands. ExecuteUpdateandExecuteUpdateAsyncsend a single SQLUPDATEstatement to the database. No rows are loaded, and the change tracker is skipped.- For large sets,
ExecuteUpdatecan 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
LastModifiedyourself insideSetProperty, since interceptors will not fire. - A single
ExecuteUpdateis its own transaction. Group several operations inBeginTransactionAsyncwhen they must all-or-nothing. - When each row needs a different value, batch with
SaveChangesor 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
- Efficient Updating - EF Core | Microsoft Learn
- ExecuteUpdate and ExecuteDelete - EF Core | Microsoft Learn
- What You Need To Know About EF Core Bulk Updates - Milan Jovanovic
- Bulk Operations in EF Core 10: Benchmarking Insert, Update, and Delete - codewithmukesh
- How to Use Bulk Updates in Entity Framework Core - Code Maze
Related Posts
The Correct Way to Use Batch Update and Batch Delete in EF Core
Learn the correct, safe way to use ExecuteUpdate and ExecuteDelete batch methods in EF Core, with transactions, change tracker tips, and EF Core 10 features.
How I Made My EF Core Query Faster With Batching
A beginner-friendly guide to EF Core batching. Learn how SaveChanges groups SQL into fewer database trips, how to tune MaxBatchSize, and when it helps.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
EF Core Bulk Insert: Boost Performance with Entity Framework Extensions
Learn how EF Core bulk insert with Entity Framework Extensions saves data faster, using simple examples, diagrams, and clear performance comparisons.
5 EF Core Features You Need to Know (Beginner Friendly)
Learn 5 must-know EF Core features with simple examples: change tracking, AsNoTracking, eager loading, bulk ExecuteUpdate, and query filters.
Calling Views, Stored Procedures and Functions in EF Core
A friendly, beginner guide to calling database views, stored procedures, and functions in EF Core with FromSql, SqlQuery, ExecuteSql, and ToView.