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:
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:
var rowsAffected = await context.Products
.Where(p => p.Category == "Electronics")
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 1.10m));
This generates:
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:
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:
var rowsDeleted = await context.Products
.Where(p => p.IsDiscontinued && p.Stock == 0)
.ExecuteDeleteAsync();
Generates:
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:
// 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:
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:
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:
// 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:
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
- Price updates, status changes, archiving — any operation affecting many rows with a consistent transformation
- Data cleanup and maintenance — removing old records, resetting flags
- Migrations and one-off scripts — updating data shapes after schema changes
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.