Skip to main content
SEMastery
Data Accessintermediate

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.

12 min readUpdated December 19, 2025

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.

The slow load-change-save round trip for each row

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.

The bulk update path: one statement, one round trip

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.

PointLoad + SaveChangesExecuteUpdate / ExecuteDelete
Loads rows into memory?Yes, every matched rowNo
Uses change tracker?YesNo
Round trips to databaseMany (or batched)Exactly one
Memory usedHigh for big setsTiny
Speed for many rowsSlowVery fast
Runs SaveChanges?Yes, you must call itNo, 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

Filter
Setters
Translate
Execute

Steps

1

Filter

Where picks the rows

2

Setters

SetProperty lists the columns

3

Translate

EF makes one UPDATE

4

Execute

DB changes rows at once

From LINQ to a single SQL statement

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 200

The 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().

Why a loaded entity goes stale after a bulk update

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

Begin
Update
Delete
Commit

Steps

1

Begin

Start a transaction

2

Update

Archive the orders

3

Delete

Clear the cart items

4

Commit

Save both at once

Both succeed together or neither does

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 filterYesThe core use case
Delete many rows by a Where filterYesUse ExecuteDelete
Set a column from another columnYesLike p => p.Price + 5
Update two different tables in one callNoRun two calls, use a transaction
Use it on a TPT inheritance hierarchyNoNot supported for ExecuteDelete/ExecuteUpdate on TPT mapping
Have related entities cascade in memoryNoThe DB cascade rules still apply, but EF memory is not updated
Run your SaveChanges interceptorsNoThese bypass the normal save pipeline

A few of these deserve extra attention:

  • One table per call. Each ExecuteUpdate or ExecuteDelete targets 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 SaveChanges will 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 Where and 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 SaveChanges is cleaner there.
A simple decision flow for choosing the right method

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 / ExecuteUpdateAsync and ExecuteDelete / ExecuteDeleteAsync, added in EF Core 7.
  • They run immediately as one SQL statement and one round trip. You do not call SaveChanges afterward.
  • 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 SaveChanges interceptors, 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

Related Posts