What You Need to Know About EF Core Bulk Updates
A friendly guide to EF Core bulk updates with ExecuteUpdate and ExecuteDelete. Change many rows in one fast SQL trip, plus the traps to avoid.
Think about your school library. One day the librarian decides every book in the "Science" shelf must get a new sticker that says "2026 Edition".
There are two ways she can do this.
The slow way: she carries each book to her desk one at a time, peels off the old sticker, sticks the new one, then walks the book back to the shelf. With 500 books, that is 500 trips. Her legs will hurt by lunch.
The fast way: she takes the sticker roll, walks to the Science shelf once, and updates every book right there on the shelf. One trip. Done in minutes.
EF Core gives us this same "fix it right on the shelf" power for databases. It is called a bulk update. Instead of pulling rows into your program, changing them, and pushing them back, you tell the database directly: "Change all these rows like this." This guide explains how it works, the exact methods to use, and the traps that trip up many developers.
The two old ways and why they hurt
Before we learn the fast way, we need to feel the pain of the slow way. That makes the fast way easy to appreciate.
Say you have a Products table and you want to raise the price of every chocolate by 5 rupees. The classic EF Core approach looks like this.
// The slow "load, change, save" way
var chocolates = await dbContext.Products
.Where(p => p.Category == "Chocolate")
.ToListAsync();
foreach (var product in chocolates)
{
product.Price += 5;
}
await dbContext.SaveChanges();This works, but look at what really happens under the hood. EF Core runs a SELECT to pull every chocolate row into memory. It builds a tracked C# object for each one. It watches each object for changes. Then SaveChanges writes a separate UPDATE statement for each row.
If there are 50,000 chocolates, you just loaded 50,000 objects into your app's memory and sent thousands of small commands to the database. That is slow, and it eats memory.
Now compare that to the librarian who fixes books right on the shelf. That is exactly what bulk updates do.
Meet ExecuteUpdate and ExecuteDelete
EF Core 7 introduced two methods that changed the game: ExecuteUpdate and ExecuteDelete (with their async friends ExecuteUpdateAsync and ExecuteDeleteAsync).
These methods do not load any rows. They translate your LINQ query straight into a single SQL UPDATE or DELETE statement and send it to the database in one trip. The database does all the work itself.
Here is the same chocolate price change, the fast way.
// The fast "fix it on the shelf" way
await dbContext.Products
.Where(p => p.Category == "Chocolate")
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price + 5));That is the whole thing. No ToList. No foreach. No SaveChanges. EF Core turns it into roughly this SQL:
UPDATE [Products]
SET [Price] = [Price] + 5
WHERE [Category] = N'Chocolate';One statement. One round trip. The database updates all matching rows in place.
Deleting is even simpler. To remove every product that has been discontinued:
await dbContext.Products
.Where(p => p.IsDiscontinued)
.ExecuteDeleteAsync();This becomes a single DELETE ... WHERE statement. No rows are loaded first.
How much faster is it really?
The difference is not small. Because bulk updates skip loading data, skip change tracking, and use a single round trip, they can be hundreds of times faster than the loop-and-save method when many rows are involved. Community benchmarks often report ExecuteUpdate and ExecuteDelete running 300 to 500 times faster than a SaveChanges loop for large batches.
Here is a simple comparison of the two styles.
| Point | Load + SaveChanges | ExecuteUpdate / ExecuteDelete |
|---|---|---|
| Loads rows into memory? | Yes, every matched row | No |
| Uses change tracker? | Yes | No |
| Round trips to database | Many (or batched) | Exactly one |
| Memory used | High for big sets | Tiny |
| Speed for many rows | Slow | Very fast |
| Runs SaveChanges? | Yes, you must call it | No, runs immediately |
The lesson is clear: when you want to change or delete many rows based on a condition, and you do not need each row's data in your app, reach for the bulk methods.
Setting more than one property at a time
Real updates often change several columns. You can chain SetProperty calls. Each one tells EF which column to change and what to change it to.
await dbContext.Products
.Where(p => p.Category == "Chocolate")
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price + 5)
.SetProperty(p => p.LastUpdated, p => DateTime.UtcNow)
.SetProperty(p => p.IsOnSale, p => false));There are two shapes for SetProperty:
- A constant or computed value that does not depend on the row, like
DateTime.UtcNow. - A value based on the row itself, like
p => p.Price + 5, where the new value uses the current column value.
Both styles can live in the same call. EF Core builds them all into one UPDATE statement.
How a bulk update is built
Steps
Filter
Where picks the rows
Setters
SetProperty lists the columns
Translate
EF makes one UPDATE
Execute
DB changes rows at once
The biggest trap: the change tracker does not know
This is the trap that surprises almost everyone, so read it twice.
Bulk updates do not talk to EF Core's change tracker. The change tracker is the part of EF that remembers entities you loaded and watches them for edits. When you run ExecuteUpdate, the database changes, but any C# objects you already loaded keep their old values. They become stale.
Look at this example carefully.
var product = await dbContext.Products.FirstAsync(p => p.Id == 1);
// product.Price is, say, 100
await dbContext.Products
.Where(p => p.Id == 1)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, 200));
// The database row is now 200...
// ...but product.Price in memory is STILL 100!
Console.WriteLine(product.Price); // prints 100, not 200The database says 200. Your variable says 100. They disagree. If you now change product and call SaveChanges, you could accidentally write the old value back, or hit a concurrency error.
The rule is simple: do not mix a bulk update with entities you already loaded for the same rows. If you need the fresh value after a bulk update, reload it from the database, or call dbContext.Entry(product).ReloadAsync().
Transactions: nothing is wrapped for you
Another important point: ExecuteUpdate and ExecuteDelete do not start a transaction on their own. Each call runs by itself.
That is fine for a single bulk update. But imagine you must update one table and delete from another, and both must succeed together or not at all. If you run them as two separate calls with no transaction, and the second one fails, the first one is already saved. Your data is now half-changed.
To keep two or more bulk operations safe, wrap them in an explicit transaction.
await using var transaction = await dbContext.Database.BeginTransactionAsync();
await dbContext.Orders
.Where(o => o.CustomerId == customerId)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Archived"));
await dbContext.CartItems
.Where(c => c.CustomerId == customerId)
.ExecuteDeleteAsync();
await transaction.CommitAsync();Now both operations live inside one transaction. If the second one throws, the transaction is never committed, and the first change rolls back too. All or nothing.
Two bulk ops in one transaction
Steps
Begin
Start a transaction
Update
Archive the orders
Delete
Clear the cart items
Commit
Save both at once
What you can and cannot do
Bulk methods are powerful but they have limits. Knowing them up front saves hours of confusion.
| You want to... | Supported? | Notes |
|---|---|---|
Update many rows by a Where filter | Yes | The core use case |
Delete many rows by a Where filter | Yes | Use ExecuteDelete |
| Set a column from another column | Yes | Like p => p.Price + 5 |
| Update two different tables in one call | No | Run two calls, use a transaction |
| Use it on a TPT inheritance hierarchy | No | Not supported for ExecuteDelete/ExecuteUpdate on TPT mapping |
| Have related entities cascade in memory | No | The DB cascade rules still apply, but EF memory is not updated |
Run your SaveChanges interceptors | No | These bypass the normal save pipeline |
A few of these deserve extra attention:
- One table per call. Each
ExecuteUpdateorExecuteDeletetargets a single table. Need to touch two tables? Use two calls. - No change-tracking side effects. Things like audit logic or soft-delete interceptors that hook into
SaveChangeswill not run. If your project relies on those, you may need to handle them another way. - The query must be translatable. EF must be able to turn your
Whereand your setters into SQL. If you use a method EF cannot translate, you get an error.
What is new in EF Core 10
EF Core has kept improving these methods. EF Core 10 (which ships on the .NET 10 LTS runtime) brought two welcome upgrades.
1. Regular lambdas for dynamic updates. In earlier versions, the setters had to be an expression tree. That made it painful to build updates dynamically, for example when you only know at runtime which columns to change. EF Core 10 lets you write the update body as a normal lambda, so building dynamic updates is far simpler and needs less boilerplate.
2. JSON column support. If you store JSON documents in a relational column, EF Core 10 lets you reference those JSON columns and the properties inside them within ExecuteUpdate. This means you can bulk-update document-style data without loading it first.
// EF Core 10: updating a property inside a JSON column (illustrative)
await dbContext.Customers
.Where(c => c.Country == "India")
.ExecuteUpdateAsync(s => s
.SetProperty(c => c.Preferences.Newsletter, true));These changes make bulk updates fit even more situations. Note that the older expression-tree style still works, so existing code keeps running.
When should you actually use bulk updates?
Bulk methods are not for every situation. Use this quick guide.
Great fit:
- Mark every unpaid invoice older than 30 days as
Overdue. - Delete all expired sessions or temporary records.
- Apply a flat price change to a whole category.
- Anonymize or clear fields for many users at once.
Poor fit:
- You need to run business rules on each row before changing it.
- You need audit logging or soft-delete behavior that lives in
SaveChanges. - You are changing just one or two entities you already loaded and edited. A normal
SaveChangesis cleaner there.
A small but real example
Let us tie it together with a tiny, believable task. A shop runs a festival sale. Every product in the "Sweets" category gets a 10 percent discount and a sale flag. After the festival, those flags must be cleared and prices restored. Here is the sale-start code.
public async Task StartFestivalSaleAsync()
{
await using var tx = await _db.Database.BeginTransactionAsync();
await _db.Products
.Where(p => p.Category == "Sweets")
.ExecuteUpdateAsync(s => s
.SetProperty(p => p.Price, p => p.Price * 0.9m)
.SetProperty(p => p.IsOnSale, true)
.SetProperty(p => p.LastUpdated, DateTime.UtcNow));
await tx.CommitAsync();
}No loop. No memory load. One statement, wrapped safely in a transaction. If you had loaded any of these products earlier in the same DbContext, remember to reload them before you use their prices again.
Quick recap
- A bulk update changes or deletes many rows directly in the database, without loading them into your app. Think of fixing books right on the shelf instead of carrying each one to a desk.
- The methods are
ExecuteUpdate/ExecuteUpdateAsyncandExecuteDelete/ExecuteDeleteAsync, added in EF Core 7. - They run immediately as one SQL statement and one round trip. You do not call
SaveChangesafterward. - They can be hundreds of times faster than the load-change-save loop for large sets, and use far less memory.
- Biggest trap: the change tracker is not updated. Entities you already loaded become stale. Reload them if you need fresh values.
- They do not start a transaction for you. Wrap multiple operations in an explicit transaction when they must succeed together.
- Limits: one table per call, no
SaveChangesinterceptors, and no support on TPT inheritance hierarchies. - EF Core 10 added regular-lambda setters for easy dynamic updates and support for updating JSON columns.
References and further reading
- ExecuteUpdate and ExecuteDelete - Microsoft Learn
- Efficient Updating - EF Core - Microsoft Learn
- What's New in EF Core 10 - Microsoft Learn
- What You Need To Know About EF Core Bulk Updates - Milan Jovanovic
- How to Use Bulk Updates in Entity Framework Core - Code Maze
Related Posts
How to Use the New Bulk Update Feature in EF Core 7
Learn EF Core 7 bulk updates with ExecuteUpdate and ExecuteDelete. Update or delete many rows in one fast SQL trip, no entity loading needed.
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.
Fast SQL Bulk Inserts With C# and EF Core: A Beginner Guide
Learn fast SQL bulk inserts in C# and EF Core using AddRange, batching, SqlBulkCopy, and bulk libraries, with simple diagrams and clear examples.
The Real Cost of Returning the Identity Value in EF Core
Why EF Core asking the database for the new Id after every insert costs round trips, and how HiLo, sequences, and Guids cut that cost down.
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.
How I Increased a Production Payment System's Performance by 15x With EF Core Extensions
A true-to-life story of making a slow EF Core payment system 15x faster using bulk extensions, ExecuteUpdate, and ExecuteDelete with simple examples.