Skip to main content
SEMastery
Data Accessintermediate

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.

13 min readUpdated December 15, 2025

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 customer

If 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();
The slow path loads every row, the fast path sends one statement.

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.

StepSaveChanges wayExecuteUpdate / ExecuteDelete way
Read rows firstYes, all of themNo
Use memory for entitiesYes, can be hugeAlmost none
Change trackingYesNo
SQL commands sentOne per rowOne total
Round trips to databaseAt least twoOne

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

Filter
Setters
SQL
Database

Steps

1

Filter

Where(...) picks the rows

2

Setters

SetProperty says what to change

3

SQL

EF Core builds one UPDATE

4

Database

All rows change at once

From your LINQ filter to a single SQL statement.

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 150

If 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 batch method updates the database but leaves your in-memory copy stale.

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 150

What 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.

FeatureWorks with SaveChanges?Works with ExecuteUpdate / ExecuteDelete?
Change trackingYesNo
SaveChanges eventsYesNo
EF Core interceptorsYesNo
Optimistic concurrency checkYesNo (you do it yourself)
EF Core in-memory cascade deleteYesNo (database cascade only)
Auditing fields set in SaveChangesYesNo (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 3

This 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.

A transaction keeps several commands all-or-nothing.

Safe multi-step batch work

Begin
Run commands
Commit
Rollback

Steps

1

Begin

Open a transaction

2

Run commands

ExecuteUpdate / ExecuteDelete

3

Commit

Keep everything if all succeed

4

Rollback

Undo everything on any error

The pattern to follow whenever you combine commands.

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.

EF Core 10 lets C# control flow choose which setters to apply.

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

  • ExecuteUpdate and ExecuteDelete send 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 with BeginTransaction.
  • 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 if statements and loops in the setters, as long as each value stays SQL-translatable.
  • Use them for many rows; keep SaveChanges for small, tracked, graph-based work.

References and further reading

Related Posts