Bulk Operations in EF Core: ExecuteUpdate, ExecuteDelete, and Beyond

Before EF Core 7, updating or deleting multiple rows required loading every entity into memory, modifying each one, and calling SaveChanges(). For a thousand rows, that meant a thousand individual UPDATE statements. EF Core 7 introduced ExecuteUpdate and ExecuteDelete — LINQ-based bulk operations that translate directly to single SQL statements.

The Old Way

Updating all products in a category used to look like this:

Example.cs
var products = await context.Products
    .Where(p => p.Category == "Electronics")
    .ToListAsync();

foreach (var product in products)
{
    product.Price *= 1.10m; // 10% price increase
}

await context.SaveChangesAsync();

This loads every matching row into memory, tracks each entity, and generates individual UPDATE statements. For 10,000 products, that's 10,000 SQL commands plus the memory overhead of tracking them all.

ExecuteUpdate

ExecuteUpdate translates your LINQ expression into a single SQL UPDATE:

Example.cs
var rowsAffected = await context.Products
    .Where(p => p.Category == "Electronics")
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.Price, p => p.Price * 1.10m));

This generates:

query.sql
UPDATE [Products]
SET [Price] = [Price] * 1.10
WHERE [Category] = N'Electronics'

One round trip. No entities loaded. No change tracking overhead.

Setting Multiple Properties

Chain SetProperty calls to update several columns at once:

Example.cs
await context.Products
    .Where(p => p.Category == "Clearance")
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.Price, p => p.Price * 0.50m)
        .SetProperty(p => p.IsOnSale, true)
        .SetProperty(p => p.LastModified, DateTime.UtcNow));

ExecuteDelete

Similarly, ExecuteDelete generates a single DELETE statement:

Example.cs
var rowsDeleted = await context.Products
    .Where(p => p.IsDiscontinued && p.Stock == 0)
    .ExecuteDeleteAsync();

Generates:

query.sql
DELETE FROM [Products]
WHERE [IsDiscontinued] = 1 AND [Stock] = 0

Combining with Complex Queries

These methods work with the full power of LINQ. You can use subqueries, joins, and aggregates:

Example.cs
// Delete orders older than a year that have been fully shipped
await context.Orders
    .Where(o => o.PlacedAt < DateTime.UtcNow.AddYears(-1))
    .Where(o => o.Items.All(i => i.ShippedAt != null))
    .ExecuteDeleteAsync();

// Update products that have no recent orders
await context.Products
    .Where(p => !context.OrderItems
        .Any(oi => oi.ProductId == p.Id
            && oi.Order.PlacedAt > DateTime.UtcNow.AddMonths(-6)))
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(p => p.IsDiscontinued, true));

Important Caveats

No change tracker integration. ExecuteUpdate and ExecuteDelete bypass the change tracker entirely. If you have entities loaded in memory, they won't reflect the changes:

Example.cs
var product = await context.Products.FindAsync(1);
// product.Price is 10.00

await context.Products
    .Where(p => p.Id == 1)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, 15.00m));

// product.Price is STILL 10.00 in memory!
// The tracked entity was not updated

If you need the in-memory state to be accurate after a bulk operation, either reload the entities or clear the change tracker:

Example.cs
context.ChangeTracker.Clear();

No interceptor/SaveChanges events. These operations don't trigger SaveChangesInterceptor. Audit logic in SavingChanges won't fire. If you rely on interceptors for auditing, you'll need to handle bulk operations separately.

No cascade deletes in memory. ExecuteDelete generates SQL DELETE statements. Database-level cascade rules apply, but EF Core's in-memory cascade tracking does not.

Bulk Inserts

EF Core doesn't have a built-in bulk insert equivalent (like ExecuteInsert). For large-volume inserts, consider:

Example.cs
// EF Core batches these automatically (default batch size varies by provider)
context.Products.AddRange(products);
await context.SaveChangesAsync();

EF Core batches AddRange inserts automatically. SQL Server batches up to 42 statements per round trip by default. For truly massive inserts (hundreds of thousands of rows), consider SqlBulkCopy:

Example.cs
using var bulkCopy = new SqlBulkCopy(connectionString);
bulkCopy.DestinationTableName = "Products";
await bulkCopy.WriteToServerAsync(dataTable);

Performance Comparison

For a table with 50,000 rows where 10,000 match the filter:

Approach Time Memory
Load + modify + SaveChanges ~8 seconds ~150 MB
ExecuteUpdate ~50 ms ~0 MB

The difference is dramatic. Bulk operations aren't just faster — they fundamentally change the scalability characteristics of your data access layer.

When to Use Bulk Operations

Stick with traditional SaveChanges() when you need change tracking, interceptors, concurrency tokens, or when operating on individual entities with complex business logic. Use ExecuteUpdate and ExecuteDelete when you're operating on sets of data and performance matters.