Skip to main content
SEMastery
Data Accessintermediate

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.

11 min readUpdated January 15, 2026

Imagine you run a small shop and you have a big notebook with the price of every item. One morning your supplier says, "All chocolates go up by 5 rupees."

You have two ways to do this.

The slow way: read each chocolate line out loud, copy it onto a separate paper, change the number on the paper, then walk back and copy it into the notebook. One item at a time. Tiring!

The fast way: take a pen, flip to the chocolate section, and just strike through and rewrite the prices right there in the notebook. No copying. No walking back and forth.

EF Core 7 gave us that second, faster way for databases. It is called bulk update using two new methods: ExecuteUpdate and ExecuteDelete. This guide will teach you how they work, when to use them, and the traps to avoid.

The old way versus the new way

Before EF Core 7, if you wanted to change many rows, you had to do the "slow notebook" steps. Let us see it.

// The OLD way: load, change in memory, then save
var cheapBlogs = await context.Blogs
    .Where(b => b.Rating < 3)
    .ToListAsync();          // 1. Load every matching row into memory
 
foreach (var blog in cheapBlogs)
{
    blog.IsVisible = false;  // 2. Change each one in memory
}
 
await context.SaveChangesAsync(); // 3. Send one UPDATE per row

This works, but look at the cost. EF Core pulls every matching row across the network into your app's memory. It tracks each one. Then it sends a separate UPDATE statement for each row. If 50,000 blogs match, that is a lot of data moving around and a lot of SQL.

The new way does the same job in a single trip.

// The NEW way in EF Core 7: one SQL command, no loading
await context.Blogs
    .Where(b => b.Rating < 3)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(b => b.IsVisible, false));

No rows come into memory. No change tracking. EF Core turns this straight into one UPDATE statement and runs it. The database does all the work in place, just like striking through prices in the notebook.

Old way moves data back and forth; new way stays in the database.

Meet the two new methods

EF Core 7 added exactly two methods for this job. They are simple to remember.

MethodWhat it doesSQL it makes
ExecuteUpdateChanges values in matching rowsUPDATE ... SET ...
ExecuteDeleteRemoves matching rowsDELETE FROM ...

Both come in a normal version and an Async version. Use the async one (ExecuteUpdateAsync, ExecuteDeleteAsync) in web apps so you do not block threads.

Both work the same way at the start: you write a LINQ query to pick the rows, then you call the method to act on them. The Where clause decides which rows. The method decides what happens.

The bulk update recipe

Pick rows
State changes
Run once

Steps

1

Pick rows

Use Where() to filter

2

State changes

Use SetProperty for each field

3

Run once

Call ExecuteUpdateAsync

Three small steps, every time.

Using ExecuteUpdate step by step

Let us build a real example. Say we have a Book table, and there is a sale. Every book in the "Fiction" category should drop to 90% of its price, and we want to stamp the time we changed it.

var now = DateTime.UtcNow;
 
int rowsChanged = await context.Books
    .Where(b => b.Category == "Fiction")
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(b => b.Price, b => b.Price * 0.9m)
        .SetProperty(b => b.UpdatedAtUtc, now));

A few things to notice here.

First, SetProperty is called twice. You chain one SetProperty per field you want to change. The first changes Price, the second changes UpdatedAtUtc.

Second, look at the two ways to give a value:

  • SetProperty(b => b.UpdatedAtUtc, now) sets a fixed value (the same now for every row).
  • SetProperty(b => b.Price, b => b.Price * 0.9m) sets a value based on the existing column. The second lambda reads each row's own Price and multiplies it. The database does this math itself.

Third, the method returns an int. That is the number of rows changed. Handy for logging or for checking that something actually matched.

The SQL EF Core produces looks roughly like this:

UPDATE [Books]
SET [Price] = [Price] * 0.9,
    [UpdatedAtUtc] = @now
WHERE [Category] = N'Fiction';

One statement. No book ever travelled to your app. That is the whole point.

How a LINQ ExecuteUpdate becomes one SQL statement.

Using ExecuteDelete

ExecuteDelete is even simpler because you only pick rows. There is nothing to set. You just say which rows should go.

Suppose we want to delete every log entry older than 30 days.

var cutoff = DateTime.UtcNow.AddDays(-30);
 
int deleted = await context.AuditLogs
    .Where(log => log.CreatedAtUtc < cutoff)
    .ExecuteDeleteAsync();

That is it. EF Core sends one DELETE FROM AuditLogs WHERE CreatedAtUtc < @cutoff. The database removes all matching rows in one go. No loading, no looping.

This is great for cleanup jobs that run at night and clear out stale data. The old way would have loaded thousands of rows just to throw them away, which is wasteful.

When should you use bulk operations?

Bulk methods are powerful, but they are not always the right tool. Here is a simple way to decide.

SituationUse bulk?Why
Change one field on many rows by a ruleYesOne SQL trip, no loading
Delete lots of old rowsYesFast cleanup, low memory
You already loaded and edited one entityNoJust use SaveChanges
Complex logic per row in C#NoBulk runs in SQL, not C#
You need EF validation or events per rowNoBulk skips the tracking pipeline

The rule of thumb: if you can describe the change as a single SQL rule ("set X to Y where Z"), bulk operations shine. If each row needs its own custom C# logic, the normal track-and-save path is better.

Pick the right tool

Many rows?
Same rule?
Choose

Steps

1

Many rows?

A few rows = SaveChanges is fine

2

Same rule?

One rule for all = bulk fits

3

Choose

Bulk for speed, SaveChanges for control

Bulk for set-based rules, SaveChanges for per-entity edits.

Important things to remember

These methods are fast because they skip a lot of EF Core's normal machinery. That speed comes with a few rules you must keep in mind. Skipping these has surprised many developers.

1. They run immediately, not on SaveChanges

ExecuteUpdate and ExecuteDelete do not wait for SaveChanges. The moment the line runs, the database changes. So do not call SaveChanges after them expecting it to "commit" the bulk work. It already happened.

2. Tracked entities go stale

EF Core does not keep your in-memory entities in sync with a bulk operation. Look at this trap.

var book = await context.Books.FirstAsync(b => b.Id == 5);
// book.Price is 100 right now
 
await context.Books
    .Where(b => b.Id == 5)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Price, 90m));
 
// In the DATABASE the price is now 90.
// But book.Price in memory is STILL 100. It is stale!

If you need the fresh value in memory, reload the entity after the bulk call. EF cannot guess that you changed it behind its back.

3. No automatic transaction across multiple calls

If you make several bulk calls, they are not wrapped in one transaction by default. Each runs on its own. If the first succeeds and the second fails, the first is not rolled back.

When two calls must succeed or fail together, wrap them yourself.

await using var tx = await context.Database.BeginTransactionAsync();
 
await context.Orders
    .Where(o => o.Status == "Pending")
    .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Cancelled"));
 
await context.OrderItems
    .Where(i => i.Order.Status == "Cancelled")
    .ExecuteDeleteAsync();
 
await tx.CommitAsync(); // Both succeed together, or neither does

4. One table at a time

Each call touches a single table only. You cannot update Orders and Customers in the same ExecuteUpdate. Run two calls instead. With inheritance mapping, this matters too: the simple TPH (table-per-hierarchy) strategy usually works without trouble, but TPT (table-per-type) can fail because the data lives across several tables.

State of an entity before and after a bulk update.

A complete, realistic example

Let us put it all together. Imagine a nightly job for an online store. It must:

  1. Mark all "Pending" orders older than 7 days as "Expired".
  2. Delete abandoned shopping carts older than 30 days.

Both should run safely. Here is the full method.

public async Task RunNightlyCleanupAsync(AppDbContext context)
{
    var weekAgo = DateTime.UtcNow.AddDays(-7);
    var monthAgo = DateTime.UtcNow.AddDays(-30);
 
    await using var tx = await context.Database.BeginTransactionAsync();
 
    int expired = await context.Orders
        .Where(o => o.Status == "Pending" && o.CreatedAtUtc < weekAgo)
        .ExecuteUpdateAsync(s => s
            .SetProperty(o => o.Status, "Expired")
            .SetProperty(o => o.UpdatedAtUtc, DateTime.UtcNow));
 
    int removed = await context.Carts
        .Where(c => c.LastTouchedUtc < monthAgo)
        .ExecuteDeleteAsync();
 
    await tx.CommitAsync();
 
    Console.WriteLine($"Expired {expired} orders, removed {removed} carts.");
}

This one method replaces what used to be hundreds of loaded entities and hundreds of small SQL commands. It runs as two clean statements inside one safe transaction. It is short, fast, and easy to read.

Common mistakes and how to fix them

New learners hit the same bumps. Here is a short list so you can skip the pain.

  • Calling SaveChanges afterward and wondering why nothing extra happens. The bulk call already saved. The extra SaveChanges simply finds nothing to do. Remove it.
  • Reusing a stale entity. You ran a bulk update, then printed an old in-memory object and got the old value. Reload the entity with a fresh query if you need the new value.
  • Forgetting the Where clause. Without Where, your update or delete hits every row in the table. That is rarely what you want and can be very hard to undo. Always double-check the filter first.
  • Expecting per-row events to fire. Bulk methods skip change tracking, so any SaveChanges interceptors, audit hooks, or SavingChanges events will not run for these rows. If you depend on those, the bulk path is not for you.
  • Mixing bulk and tracked edits carelessly. If you edit a tracked entity in memory and also bulk-update the same row, the two can fight. Pick one approach per row and stay with it.

A small habit helps a lot: read your Where clause out loud before running the command. "Update price where category is Fiction." If that sentence is true and complete, you are safe.

A quick note on newer versions

EF Core 7 introduced these methods. Since then they have only gotten better. In EF Core 10 (the current LTS release alongside .NET 10 and C# 14), ExecuteUpdate gained nice extras like conditional updates written with normal lambda syntax and support for bulk-updating JSON columns. If you are on a newer version, the EF Core 7 knowledge here still applies fully. You just have more power on top.

If you only need to insert, update, or delete a handful of rows that you have already loaded and edited, keep using the classic SaveChanges flow. Bulk methods are for the big, rule-based jobs.

Quick recap

  • EF Core 7 added two bulk methods: ExecuteUpdate (changes rows) and ExecuteDelete (removes rows).
  • The recipe is always: filter with Where, then act with the method. Use SetProperty once per field you change.
  • They run as one SQL statement and never load rows into memory, so they are much faster for big jobs.
  • They run immediately. Do not call SaveChanges after them.
  • They do not keep tracked entities in sync. Reload if you need fresh values in memory.
  • Multiple calls are not in one transaction by default. Wrap them yourself when they must succeed together.
  • Each call works on one table only.
  • Use bulk for set-based rules. Use SaveChanges for per-entity, custom-logic edits.

References and further reading

Related Posts