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.
Imagine you run a small grocery shop. One evening, you decide every packet of biscuits should cost 5 rupees more because the supplier raised the price. You have two ways to do this.
The slow way: pick up each biscuit packet, read its price sticker, peel it off, write a new price, and stick it back. Packet by packet. If you have 500 packets, that is 500 trips to the shelf.
The fast way: stand at the counter and announce, "From now on, every biscuit packet costs 5 rupees more." One sentence. The whole shelf is updated at once.
EF Core has the same two ways of changing data. The slow way loads each row into memory and updates it one by one. The fast way sends a single instruction to the database: "Add 5 to the price of every biscuit." That fast way is what ExecuteUpdate and ExecuteDelete give you. They are sometimes called the batch update and batch delete methods.
They are powerful. But like any sharp tool, they cut well only when held correctly. This article shows you the correct way to use them.
What are ExecuteUpdate and ExecuteDelete?
These two methods arrived in EF Core 7 and got even better in EF Core 10 (the current LTS release on .NET 10). They let you update or delete many rows with one SQL statement, without first loading those rows into your application.
Here is the old way of giving every customer in Mumbai a discount tag:
// The old way: load, change in memory, then save
var customers = await dbContext.Customers
.Where(c => c.City == "Mumbai")
.ToListAsync();
foreach (var customer in customers)
{
customer.IsPremium = true;
}
await dbContext.SaveChanges(); // sends one UPDATE per customerIf 10,000 customers live in Mumbai, EF Core pulls all 10,000 rows across the network, fills your memory with them, then sends 10,000 UPDATE statements. That is slow and heavy.
Here is the same job using ExecuteUpdate:
// The new way: one SQL statement, nothing loaded into memory
await dbContext.Customers
.Where(c => c.City == "Mumbai")
.ExecuteUpdateAsync(setters => setters
.SetProperty(c => c.IsPremium, true));This sends a single SQL command, something like:
// The SQL EF Core generates (shown as a string for clarity)
var sql = @"UPDATE [Customers]
SET [IsPremium] = 1
WHERE [City] = N'Mumbai';";No rows are loaded. No memory is filled. The database does all the work in one shot. The same idea works for deleting:
// Delete every order older than one year, in a single statement
await dbContext.Orders
.Where(o => o.CreatedAt < DateTime.UtcNow.AddYears(-1))
.ExecuteDeleteAsync();Why they are so much faster
The speed comes from skipping three steps that SaveChanges always does: loading rows, tracking them, and sending one command per row.
| Step | SaveChanges way | ExecuteUpdate / ExecuteDelete way |
|---|---|---|
| Read rows first | Yes, all of them | No |
| Use memory for entities | Yes, can be huge | Almost none |
| Change tracking | Yes | No |
| SQL commands sent | One per row | One total |
| Round trips to database | At least two | One |
For one or two rows, the difference is tiny. But when you touch hundreds or thousands of rows, batch methods can be many times faster and use far less memory.
How ExecuteUpdate runs
Steps
Filter
Where(...) picks the rows
Setters
SetProperty says what to change
SQL
EF Core builds one UPDATE
Database
All rows change at once
The big catch: the change tracker does not know
This is the most important idea in the whole article, so read it slowly.
ExecuteUpdate and ExecuteDelete go straight to the database. They do not look at EF Core's change tracker, and they do not update any entities you already loaded into memory.
Picture this. You load a product. Its price is 100. Then you run a batch update that sets the price to 150 in the database. Your loaded product still says 100 in memory, because the batch method never touched it. The database and your memory now disagree. We call this a stale entity.
// Load a product. Its Price is 100 in the database.
var product = await dbContext.Products.FirstAsync(p => p.Id == 1);
// Batch update sets Price to 150 directly in the database.
await dbContext.Products
.Where(p => p.Id == 1)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, 150m));
// DANGER: product.Price is STILL 100 here.
// The change tracker never heard about the update.
Console.WriteLine(product.Price); // prints 100, not 150If you now change something else on product and call SaveChanges, EF Core might write the old price of 100 back over your new 150. That is a real bug.
The simple rule to stay safe
Use batch methods on rows you have not loaded, or reload the entity afterwards if you need a fresh copy. The cleanest habit: run your batch operations on their own, and do not mix them with tracked entities in the same block of work.
If you really must refresh a loaded entity after a batch update, reload it:
// Force EF Core to read the latest values from the database
await dbContext.Entry(product).ReloadAsync();
Console.WriteLine(product.Price); // now prints 150What batch methods do NOT do
Because they skip the change tracker, several friendly EF Core features simply do not run. Knowing this list saves you from surprises.
| Feature | Works with SaveChanges? | Works with ExecuteUpdate / ExecuteDelete? |
|---|---|---|
| Change tracking | Yes | No |
| SaveChanges events | Yes | No |
| EF Core interceptors | Yes | No |
| Optimistic concurrency check | Yes | No (you do it yourself) |
| EF Core in-memory cascade delete | Yes | No (database cascade only) |
| Auditing fields set in SaveChanges | Yes | No (set them by hand) |
So if you have an auditing rule that fills ModifiedAt inside SaveChanges, that rule will not fire for a batch update. You must set such fields yourself, right inside the setters.
// Set auditing fields by hand, because SaveChanges logic won't run
await dbContext.Invoices
.Where(i => i.Status == InvoiceStatus.Pending)
.ExecuteUpdateAsync(s => s
.SetProperty(i => i.Status, InvoiceStatus.Overdue)
.SetProperty(i => i.ModifiedAt, DateTime.UtcNow));Each call is its own command (no automatic batching)
The name "batch update" can confuse beginners. It means one statement changes many rows. It does not mean many ExecuteUpdate calls get bundled together.
Every call to ExecuteUpdate or ExecuteDelete is a separate trip to the database. If you call it three times, that is three round trips. They are not merged into one like the commands inside SaveChanges are.
// These are THREE separate database round trips, not one batch.
await dbContext.Orders.Where(o => o.IsCancelled)
.ExecuteDeleteAsync(); // trip 1
await dbContext.Products.Where(p => p.Stock == 0)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsHidden, true)); // trip 2
await dbContext.Logs.Where(l => l.Old)
.ExecuteDeleteAsync(); // trip 3This matters because if trip 2 fails, trip 1 has already happened and will not undo itself. Which brings us to the most important safety habit: transactions.
Always use a transaction when combining operations
A single ExecuteUpdate call is already safe on its own. EF Core wraps it in its own little transaction, so it either fully works or fully fails.
The danger appears when you do several things together and want them to be all-or-nothing. Think of a bank transfer: take money from one account, add it to another. If the second step fails, the first must be undone, or money disappears.
To get all-or-nothing safety, open your own transaction. Everything between BeginTransaction and CommitAsync succeeds together or rolls back together.
// Wrap multiple commands so they succeed or fail as one unit
await using var transaction = await dbContext.Database.BeginTransactionAsync();
try
{
await dbContext.Orders
.Where(o => o.CustomerId == customerId)
.ExecuteDeleteAsync();
await dbContext.Customers
.Where(c => c.Id == customerId)
.ExecuteDeleteAsync();
await transaction.CommitAsync(); // both deletes are kept
}
catch
{
await transaction.RollbackAsync(); // any failure undoes both
throw;
}The same applies if you mix a batch method with SaveChanges. Put both inside one transaction so a failure in one rolls back the other.
Safe multi-step batch work
Steps
Begin
Open a transaction
Run commands
ExecuteUpdate / ExecuteDelete
Commit
Keep everything if all succeed
Rollback
Undo everything on any error
Watch out for concurrency
With SaveChanges, EF Core can protect you from two people editing the same row at once using a concurrency token (like a RowVersion). It checks "is this row still the version I read?" before writing.
Batch methods do not do this check. They overwrite whatever is there. If two users run the same batch update at the same time, the last one wins and no warning is raised. When correctness matters, add your own condition to the Where so you only update rows that still match your expectation.
// Only mark as Shipped if it is still Packed, avoiding a blind overwrite
var rowsChanged = await dbContext.Orders
.Where(o => o.Id == orderId && o.Status == OrderStatus.Packed)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Shipped));
if (rowsChanged == 0)
{
// Nothing matched. Someone else already changed this order.
throw new InvalidOperationException("Order was modified by someone else.");
}Notice that both methods return the number of rows changed. That number is gold. Use it to confirm the operation did what you expected.
EF Core 10 makes conditional updates easy
In EF Core 7, 8, and 9, the setters had to be a strict expression tree. Writing a setter that changed only some fields was painful. You had to build expression trees by hand.
EF Core 10 fixed this. The setters parameter is now a normal delegate, so you can use everyday C# like if statements and loops to decide which fields to update.
// EF Core 10: use plain if statements to build a conditional update
await dbContext.Products
.Where(p => p.Id == productId)
.ExecuteUpdateAsync(setters =>
{
if (newName is not null)
{
setters.SetProperty(p => p.Name, newName);
}
if (newPrice.HasValue)
{
setters.SetProperty(p => p.Price, newPrice.Value);
}
// Always stamp the modified time
setters.SetProperty(p => p.ModifiedAt, DateTime.UtcNow);
});One rule still stands: every SetProperty must be SQL-translatable. You can use columns, constants, and simple maths, but you cannot call a random C# method that the database does not understand. The if and the loop run in C# to decide the setters; the value inside each setter must still turn into SQL.
A note on related tables
ExecuteUpdate and ExecuteDelete work on one table at a time. You can filter using related data (a join in the Where), but the actual change happens on a single table. You cannot, in one call, update a parent and its children together. If you need that, run two calls inside a transaction.
When you ExecuteDelete a parent row that has children, EF Core does not apply its own in-memory cascade delete. The deletion follows whatever rule the database has set on the foreign key. So if your database is set to "no cascade," the delete may fail because children still point to the parent. Plan your delete order, or rely on a proper database cascade rule.
When to use batch methods, and when not to
Batch methods are not always the right choice. Here is a simple guide.
Reach for ExecuteUpdate / ExecuteDelete when:
- You are changing or deleting many rows with the same rule.
- You do not need the changed entities in memory afterwards.
- You want top speed and low memory use.
Stick with SaveChanges when:
- You are working with a few entities you already loaded.
- You rely on the change tracker, interceptors, events, or concurrency tokens.
- You need complex object graphs (parent and children) saved together.
There is no shame in using SaveChanges. It is friendly, safe, and smart. Batch methods are a focused tool for one specific job: changing lots of rows fast.
Quick recap
ExecuteUpdateandExecuteDeletesend one SQL statement to change or delete many rows, with no rows loaded into memory. This makes them fast and light.- They bypass the change tracker. Entities already in memory become stale. Reload them with
Entry(entity).ReloadAsync()if you still need them. - They skip interceptors, SaveChanges events, in-memory cascade delete, and concurrency checks. Set auditing fields and concurrency conditions yourself.
- "Batch" means one statement for many rows. It does not bundle multiple calls. Each call is a separate database round trip.
- A single call is already atomic, but wrap multiple commands (or a mix with
SaveChanges) in a transaction withBeginTransaction. - Both methods return the row count, which you should check to confirm the result and detect lost updates.
- EF Core 10 lets you use plain
ifstatements and loops in the setters, as long as each value stays SQL-translatable. - Use them for many rows; keep
SaveChangesfor small, tracked, graph-based work.
References and further reading
- ExecuteUpdate and ExecuteDelete - Microsoft Learn
- Breaking changes in EF Core 10 - Microsoft Learn
- What You Need To Know About EF Core Bulk Updates - Milan Jovanovic
- A Correct Way to Use BatchUpdate and BatchDelete Methods in EF Core - Anton Martyniuk
- EF Core ExecuteUpdate (EF Core 7-10) - Learn Entity Framework Core
Related Posts
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.
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.
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.