Bulk updates and deletes in EF Core without loading every row
Use ExecuteUpdate and ExecuteDelete in EF Core 7+ to issue a single SQL statement, but know what change tracking skips.
28 Mar 2026, 01:06 UTC

The problem: loading thousands of rows just to flip one column
Your nightly job archives orders older than 13 months. The familiar EF Core approach is to load every matching order into the DbContext, change the Status property, and call SaveChanges. That works until the table grows. Loading tens of thousands of entities consumes memory, and SaveChanges emits one UPDATE statement per row.
EF Core 7 added ExecuteUpdate and ExecuteDelete on IQueryable. These methods translate a query into a single UPDATE or DELETE command sent directly to the database, without instantiating entity objects. This post explains when to use them, what they skip, and how to verify the SQL your provider generates.
Version assumption: EF Core 7.0 or later with a relational provider such as SQL Server, PostgreSQL, or SQLite.
How ExecuteUpdate and ExecuteDelete differ from SaveChanges
ExecuteUpdate applies changes to matching rows without loading them. ExecuteDelete removes matching rows the same way. EF Core builds the command from the query predicate and a setters expression, so no entity instances are created.
using var context = new AppDbContext();
var cutoff = DateTimeOffset.UtcNow.AddMonths(-13);
int affected = await context.Orders
.Where(o => o.OrderDate < cutoff)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, OrderStatus.Archived)
.SetProperty(o => o.UpdatedAt, DateTimeOffset.UtcNow));
The affected variable holds the row count returned by the database. Because entities are never materialized, this avoids the memory and round-trip cost of loading objects only to persist a single column change.
Check the EF Core version
Run this in the project directory:
dotnet list package Microsoft.EntityFrameworkCore
No special permissions are required. Expected output lists Microsoft.EntityFrameworkCore 7.x.x or higher. If the package is missing or older, upgrade before using these methods.
What gets bypassed
Because change tracking does not run for affected rows, several behaviors you may expect from SaveChanges do not happen:
- EF Core cascade delete rules defined in navigation mappings are not applied by EF Core. Database-level cascade constraints may still apply, depending on your schema.
SaveChangesinterceptors and entity events do not fire for the affected rows.- Code inside entity constructors or property setters does not execute.
- Entities already tracked by the context are not refreshed. If an order was loaded earlier in the same context, its in-memory
Statusremains stale after the bulk update.
Verify the generated SQL before production
Enable EF Core logging in your context configuration:
optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information);
Alternatively, use a database profiler. Look for one UPDATE Orders SET ... WHERE ... or DELETE FROM Orders WHERE ... statement. If you see per-row commands, something is materializing entities.
Run an integration test against a real relational database provider. The in-memory provider does not translate these calls the same way and should not be your only verification.
Check the result in code:
int deleted = await context.Orders
.Where(o => o.OrderDate < cutoff)
.ExecuteDeleteAsync();
if (deleted != expectedCount)
{
// Investigate: rows may have already been deleted, or the predicate may not match as intended.
}
Trade-offs and when to keep loading entities
Use bulk methods for data-driven operations such as archiving, status flips, or cleanup. Avoid them when business logic lives in property setters, when you rely on EF Core cascades, or when tracked entities must stay consistent with the database.
Provider-specific SQL translation may differ. Test the generated SQL against your target database provider before deploying.
Closing
ExecuteUpdate and ExecuteDelete are useful when the operation is purely data-driven and you have verified the generated SQL. Keep loading entities when domain logic must run or when the context already tracks related objects.
Because these methods change state, wrap them in a transaction if you need atomicity:
using var transaction = await context.Database.BeginTransactionAsync();
try
{
int affected = await context.Orders
.Where(o => o.OrderDate < cutoff)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, OrderStatus.Archived));
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
The database user needs UPDATE or DELETE permission on the target table. A failed transaction leaves no partial changes, but long-running transactions can hold locks, so keep the scope small.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.