Updating database records with Entity Framework Core usually involves loading entities, changing their properties, and calling SaveChangesAsync(). This approach works well when an application needs to inspect individual records or apply business logic to each entity.
However, loading thousands of records just to update one property can waste database round trips, memory, and processing time.
EF Core provides ExecuteUpdate and ExecuteUpdateAsync to update multiple rows directly in the database without loading the affected entities into application memory. This can make bulk updates more efficient, especially for maintenance jobs, status changes, and operations that apply the same update to many records.
The important difference is that ExecuteUpdate executes a database update directly. It does not use the normal change-tracking workflow, so developers must understand how it interacts with tracked entities, transactions, concurrency checks, and database constraints.
How Traditional EF Core Updates Work
Consider an application that needs to mark expired subscriptions as inactive.
A traditional implementation might look like this:
using Microsoft.EntityFrameworkCore;
public async Task DeactivateExpiredSubscriptionsAsync(
AppDbContext db,
CancellationToken cancellationToken)
{
var expiredSubscriptions = await db.Subscriptions
.Where(s => s.ExpiresAt < DateTime.UtcNow)
.ToListAsync(cancellationToken);
foreach (var subscription in expiredSubscriptions)
{
subscription.IsActive = false;
}
await db.SaveChangesAsync(cancellationToken);
}
This code loads all matching subscriptions into memory. EF Core tracks the entities and detects their property changes when SaveChangesAsync() runs.
The approach is appropriate when the application needs the loaded entities for additional processing. However, if the only requirement is to set IsActive to false, loading every matching row is unnecessary.
For a large number of subscriptions, this approach can consume considerable memory and add overhead from entity materialization and change tracking.
What ExecuteUpdate Does Differently
ExecuteUpdateAsync allows EF Core to translate a LINQ query and its property assignments into a database update statement.
The same operation can be written as:
using Microsoft.EntityFrameworkCore;
public async Task DeactivateExpiredSubscriptionsAsync(
AppDbContext db,
CancellationToken cancellationToken)
{
var currentTime = DateTime.UtcNow;
await db.Subscriptions
.Where(s => s.ExpiresAt < currentTime)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(s => s.IsActive, false),
cancellationToken);
}
Instead of retrieving the matching subscriptions, EF Core sends an update directly to the database.
Conceptually, the database executes a statement similar to:
UPDATE Subscriptions
SET IsActive = 0
WHERE ExpiresAt < @currentTime;
The actual SQL depends on the database provider, schema, and query.
ExecuteUpdate was introduced in EF Core 7.0. It is useful when an operation can be expressed as a set-based database update and does not require individual entities to be loaded and tracked.
The method returns the number of rows affected. You can use that result for logging or operational metrics:
var affectedRows = await db.Subscriptions
.Where(s => s.ExpiresAt < currentTime)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(s => s.IsActive, false),
cancellationToken);
Console.WriteLine(
$"Updated {affectedRows} subscriptions.");
The affected-row count is useful for understanding the operation's impact, but it does not automatically tell you whether every business requirement was satisfied. That depends on the filtering conditions and expected data state.
When ExecuteUpdate Is a Better Choice
ExecuteUpdateAsync is a good fit when the database can perform the update without requiring application code to inspect every entity.
Common examples include:
- Marking expired subscriptions as inactive.
- Updating an order status for a group of records.
- Increasing prices by a fixed percentage.
- Setting an archive flag on old records.
- Updating a timestamp for records that meet a condition.
- Applying a simple calculation to a column.
- Correcting a value across a defined set of records.
For example, suppose a store needs to increase prices by five percent for products in a particular category.
public async Task IncreasePricesAsync(
AppDbContext db,
int categoryId,
CancellationToken cancellationToken)
{
await db.Products
.Where(p => p.CategoryId == categoryId)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
p => p.Price,
p => p.Price * 1.05m),
cancellationToken);
}
The database performs the calculation for each matching row. The application does not need to load each product and calculate its new price in C#.
Before using this approach for financial values, verify the database column type, precision, rounding requirements, and business rules. A set-based calculation should produce the same intended results as the application's established pricing logic.
Update Multiple Properties in One Operation
A single ExecuteUpdateAsync call can update multiple properties.
For example, when closing old support tickets, the application may need to change the status and record the closure time.
public async Task CloseOldTicketsAsync(
AppDbContext db,
DateTime cutoffTime,
CancellationToken cancellationToken)
{
var closedAt = DateTime.UtcNow;
await db.SupportTickets
.Where(t =>
t.CreatedAt < cutoffTime &&
t.Status == TicketStatus.Open)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
t => t.Status,
TicketStatus.Closed)
.SetProperty(
t => t.ClosedAt,
closedAt),
cancellationToken);
}
The query filters for tickets that are both old and currently open. The database updates the matching records in one set-based operation.
Using a single operation also avoids the application issuing a separate update for each ticket.
This example assumes that automatically closing tickets is valid for the business. If a ticket requires individual review, notifications, audit records, or other per-ticket behavior, those requirements must be handled separately.
ExecuteUpdate Does Not Use Change Tracking
One of the most important differences between ExecuteUpdateAsync and SaveChangesAsync() is how EF Core handles tracked entities.
Consider the following code:
var product = await db.Products
.FirstAsync(p => p.Id == 1, cancellationToken);
await db.Products
.Where(p => p.Id == 1)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(p => p.Stock, 0),
cancellationToken);
Console.WriteLine(product.Stock);
The database row has been updated, but the product object already loaded into memory is not automatically refreshed. It may still contain the previous stock value.
ExecuteUpdateAsync bypasses EF Core's change tracker. It does not synchronize tracked entities with the database after the update.
This can become a problem if the application later modifies the tracked entity and calls SaveChangesAsync(). Depending on which properties are modified, the application may overwrite the value written by the bulk update or otherwise operate on stale state.
There are several ways to avoid this problem:
- Perform the bulk update before loading entities that will be used afterward.
- Use a separate
DbContext for the bulk operation. - Reload affected entities when current database values are required.
- Clear the change tracker only when it is safe to discard all tracked state.
For example, you can reload an entity after the update:
await db.Entry(product)
.ReloadAsync(cancellationToken);
Reloading is useful when you need the latest values for a particular tracked entity. It is not a practical solution for refreshing thousands of entities individually.
Understand Transactions and Atomicity
Each ExecuteUpdateAsync call executes directly against the database. Unlike a sequence of tracked changes followed by SaveChangesAsync(), separate bulk update calls are not automatically grouped into one transaction merely because they use the same DbContext.
If an operation requires multiple updates to succeed or fail together, create an explicit transaction.
await using var transaction =
await db.Database.BeginTransactionAsync(
cancellationToken);
try
{
await db.Orders
.Where(o => o.Status == OrderStatus.Pending)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
o => o.Status,
OrderStatus.Processing),
cancellationToken);
await db.OrderSummaries
.Where(s => s.Status == OrderStatus.Pending)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
s => s.Status,
OrderStatus.Processing),
cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch
{
await transaction.RollbackAsync(
CancellationToken.None);
throw;
}
This example assumes that the two updates are part of the same business operation and that the configured database provider supports the required transaction behavior.
The transaction ensures that the database changes are committed together or rolled back together when an error occurs. It does not automatically include external operations such as sending an email or calling another service.
Keep transactions short, especially when updating large tables. Long-running transactions can increase lock duration and contention.
Handle Concurrency Explicitly
Tracked entity updates can use concurrency tokens, such as SQL Server rowversion columns, to detect when another transaction has changed an entity.
ExecuteUpdateAsync does not automatically apply EF Core's normal tracked-entity concurrency handling. If the application requires optimistic concurrency, include the expected version or state in the query and inspect the number of affected rows.
For example, suppose an order has a status and a version value:
var affectedRows = await db.Orders
.Where(o =>
o.Id == orderId &&
o.Version == expectedVersion &&
o.Status == OrderStatus.Pending)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(
o => o.Status,
OrderStatus.Processing),
cancellationToken);
if (affectedRows == 0)
{
throw new InvalidOperationException(
"The order changed or is no longer pending.");
}
This pattern works only if Version is a value that changes when the relevant record changes and the application maintains it correctly. For a SQL Server rowversion column, the database generates the new version automatically when the row is updated; the application should compare the original version in the WHERE clause.
A zero-row result can mean that the record does not exist, its version changed, or its status no longer matches. The application should interpret that result according to its business requirements.
Understand What ExecuteUpdate Does Not Do
Because ExecuteUpdateAsync executes a set-based update directly, it does not run the normal entity-by-entity update pipeline.
In particular, it does not:
- Load affected entities into memory.
- Update tracked entity instances automatically.
- Apply changes configured only in application-side property setters.
- Automatically invoke EF Core's normal tracked-entity
SaveChanges pipeline. - Automatically apply tracked-entity concurrency checks.
- Automatically group multiple calls into one transaction.
Database-level constraints, triggers, and other database mechanisms still apply according to the provider and database configuration.
If your application relies on logic in SaveChanges overrides, change-tracking interceptors, or domain methods, verify that a direct update will preserve the required behavior. Logic implemented only in those application-side mechanisms will not automatically run for ExecuteUpdateAsync.
For example, if every tracked order update must create an audit record in application code, a bulk update may bypass that audit mechanism. You would need to implement the required auditing through an appropriate database mechanism or a separate operation designed for bulk processing.
ExecuteUpdate Versus Loading Entities
The choice depends on what the application needs to do.
| Requirement | Recommended approach |
|---|
| Set the same property for many rows | ExecuteUpdateAsync |
| Calculate a new value directly from a database column | ExecuteUpdateAsync, if supported by the provider |
| Inspect each entity before deciding what to change | Load entities and update them |
| Apply domain methods or entity-specific business rules | Load entities and update them |
| Use normal change tracking and concurrency-token behavior | Tracked entities with SaveChangesAsync() |
| Update several tables atomically | Use an explicit transaction when required |
| Return the updated entity values | Use a query or reload entities as appropriate |
ExecuteUpdateAsync is not a universal replacement for tracked updates. It is a tool for operations that are naturally expressed as database set operations.
Test Bulk Updates Before Using Them in Production
Bulk updates can affect a large number of records with a single statement. A small mistake in the filter can therefore have a much wider impact than an incorrect update to one tracked entity.
Before deploying a bulk update, verify the generated SQL and the number of matching records. Test with representative data and confirm that the WHERE clause selects exactly the intended rows.
Also test:
- Behavior when no records match.
- Database constraints and triggers.
- Transaction rollback when a later operation fails.
- Concurrent changes to affected records.
- The impact on tracked entities in the same context.
- Locking, query duration, and transaction-log growth for large updates.
For especially large tables, consider appropriate indexes, execution plans, batch strategies, and operational maintenance windows. A single bulk update avoids loading entities into application memory, but it can still generate substantial database work and transaction-log activity.
Conclusion
EF Core's ExecuteUpdateAsync is useful when an application needs to update many database rows without loading them into memory. It reduces entity materialization and change-tracking overhead by letting the database perform the update directly.
Use it for set-based operations such as status changes, bulk price adjustments, and archival flags. Continue using tracked entities when individual domain logic, current in-memory state, or standard concurrency handling is required.
Before adopting it, account for stale tracked entities, explicit transaction boundaries, concurrency checks, and application-side logic that normally runs through SaveChangesAsync(). Used in the right situations, ExecuteUpdateAsync can make bulk data operations simpler and more efficient without sacrificing correctness.
Jasen FiciPosted Oct 21, 2026, 7:22 AM
This was included in DotNetNews here: https://dotnetnews.beehiiv.com/p/new-post-1407