When an application needs to update thousands of database records, loading every record into memory and modifying it one by one can become inefficient. It increases memory usage, generates additional database queries, and can make a simple bulk update unnecessarily expensive.
Entity Framework Core provides ways to update records without loading every entity into the application's memory. The right approach depends on the update logic, whether entity tracking is required, and how much control you need over the operation.
This article explains how to update large numbers of records efficiently using EF Core, including ExecuteUpdate, batching, and scenarios where loading entities is still necessary.
1. Why Loading Thousands of Records Can Be Expensive
Consider an application that needs to increase the salary of every employee in a particular department.
A straightforward implementation might look like this:
var employees = await context.Employees
.Where(e => e.DepartmentId == 10)
.ToListAsync();
foreach (var employee in employees)
{
employee.Salary += 1000;
}
await context.SaveChangesAsync();
This code works, but it performs more work than the database operation actually requires.
Here is what happens:
EF Core retrieves all matching employee records from the database.
The application stores those records in memory.
EF Core tracks the loaded entities and monitors their changes.
The application modifies each employee's salary.
SaveChanges sends update statements to the database.
EF Core can batch multiple update statements into fewer database roundtrips. However, it still needs to track the entities and generate individual update statements.
For a large number of records, this can increase memory consumption and processing overhead.
The database is already capable of updating multiple rows with a single SQL statement. Instead of transferring every record to the application, we can ask the database to perform the update directly.
2. Use ExecuteUpdateAsync for Bulk Updates
EF Core introduced ExecuteUpdate and ExecuteUpdateAsync to perform database updates without loading entities or using the change tracker.
This is particularly useful when multiple records need the same type of update.
Let's rewrite the employee salary example:
var affectedRows = await context.Employees
.Where(e => e.DepartmentId == 10)
.ExecuteUpdateAsync(setters =>
setters.SetProperty(
e => e.Salary,
e => e.Salary + 1000
));
This code performs the update directly in the database.
The generated SQL is conceptually similar to:
UPDATE Employees
SET Salary = Salary + 1000
WHERE DepartmentId = 10;
The database evaluates the condition and updates all matching rows. EF Core does not need to retrieve employee objects or track their individual property changes.
The returned affectedRows value tells you how many rows were updated.
Why is this more efficient?
ExecuteUpdateAsync provides several benefits:
No need to load matching entities into application memory.
No change-tracking overhead for the updated records.
A single database update statement for the operation.
Fewer application-to-database roundtrips.
Less application-side processing.
For straightforward updates where the same operation applies to many records, this is usually a better approach than loading entities and modifying them individually.
3. Update Multiple Columns in One Operation
Real applications often need to update more than one column.
For example, imagine an order management system where all pending orders older than a certain date need to be marked as expired. The application also needs to record when the status changed.
You can update multiple properties using SetProperty:
var cutoffDate = DateTime.UtcNow.AddDays(-30);
var affectedRows = await context.Orders
.Where(o => o.Status == "Pending"
&& o.CreatedAt < cutoffDate)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, "Expired")
.SetProperty(o => o.UpdatedAt, DateTime.UtcNow)
);
Both columns are updated through the same database operation.
You can also calculate a new value using the existing database column:
await context.Products
.Where(p => p.CategoryId == 5)
.ExecuteUpdateAsync(setters => setters
.SetProperty(p => p.Price, p => p.Price * 1.05m)
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow)
);
This increases the price of matching products by five percent.
The important distinction is that the calculation happens as part of the database update. EF Core does not need to retrieve each product's current price before calculating the new value.
4. Update Records in Smaller Batches When Necessary
ExecuteUpdateAsync is useful when one database statement can handle the entire operation.
However, updating a very large number of rows in one statement is not always the best operational choice.
A large update can:
Hold locks for longer periods.
Generate substantial transaction log activity.
Affect other queries running against the same tables.
Increase contention with other database operations.
Make recovery and troubleshooting more difficult.
In such situations, processing records in smaller batches can provide better operational control.
For example, consider an application that needs to deactivate inactive user accounts. Instead of updating every matching account at once, it can process a limited number of records during each iteration.
One approach is to select a batch of IDs and update those records:
const int batchSize = 1000;
while (true)
{
var userIds = await context.Users
.Where(u => !u.IsActive
&& u.LastLoginAt < cutoffDate
&& !u.IsArchived)
.OrderBy(u => u.Id)
.Select(u => u.Id)
.Take(batchSize)
.ToListAsync();
if (userIds.Count == 0)
{
break;
}
await context.Users
.Where(u => userIds.Contains(u.Id))
.ExecuteUpdateAsync(setters => setters
.SetProperty(u => u.IsArchived, true)
.SetProperty(u => u.UpdatedAt, DateTime.UtcNow)
);
}
Each iteration selects a limited number of IDs and updates only those records.
The additional condition !u.IsArchived is important. Once a record is updated, it no longer matches the selection condition, allowing the next iteration to move forward.
Why use smaller batches?
Batching gives you more control over the workload.
For example, if the application needs to update 100,000 records, it can process them in groups of 1,000 rather than sending one large update statement.
The batch size should be chosen based on the database workload, indexes, transaction log capacity, locking behavior, and application requirements. A batch size of 1,000 is only an example, not a universal recommendation.
For very large datasets, consider using a stable key or keyset-based approach to move through records instead of repeatedly scanning a large range.
5. Understand ExecuteUpdateAsync and SaveChangesAsync
One important difference between ExecuteUpdateAsync and SaveChangesAsync is how they interact with EF Core's change tracker.
SaveChangesAsync works with tracked entities. EF Core identifies which properties have changed and generates the necessary database commands.
ExecuteUpdateAsync works directly against the database and bypasses the change tracker.
Consider this example:
var employee = await context.Employees
.FirstAsync(e => e.Id == 101);
await context.Employees
.Where(e => e.Id == 101)
.ExecuteUpdateAsync(setters =>
setters.SetProperty(e => e.Salary, 80000)
);
employee.Salary = 75000;
await context.SaveChangesAsync();
This code can produce an unexpected result.
The employee entity was loaded before ExecuteUpdateAsync, so EF Core is still tracking its original state. ExecuteUpdateAsync changes the database directly, but it does not update the tracked employee object.
When SaveChangesAsync executes, EF Core may write the tracked value of 75000 back to the database.
The direct update to 80000 can therefore be overwritten.
How to avoid this problem
Avoid mixing tracked entity updates and ExecuteUpdateAsync operations for the same records within the same DbContext unless you deliberately manage the tracked state.
If you need to continue working with the entity after a direct update, reload it:
await context.Employees
.Where(e => e.Id == 101)
.ExecuteUpdateAsync(setters =>
setters.SetProperty(e => e.Salary, 80000)
);
await context.Entry(employee).ReloadAsync();
Alternatively, use a separate DbContext for the bulk operation.
Understanding this difference is important when implementing bulk updates in applications that use EF Core extensively.
6. Handle Transactions When Multiple Updates Must Succeed Together
ExecuteUpdateAsync executes immediately. Unlike tracked changes, it does not wait for SaveChangesAsync.
Also, multiple ExecuteUpdateAsync calls do not automatically share one transaction.
Consider an order processing operation that updates order status and inventory information:
await context.Orders
.Where(o => o.Id == orderId)
.ExecuteUpdateAsync(setters =>
setters.SetProperty(o => o.Status, "Completed")
);
await context.Products
.Where(p => p.Id == productId)
.ExecuteUpdateAsync(setters =>
setters.SetProperty(p => p.Stock, p => p.Stock - quantity)
);
If the first update succeeds but the second fails, the database can be left with an inconsistent business state.
When multiple operations must succeed or fail together, use an explicit transaction:
await using var transaction =
await context.Database.BeginTransactionAsync();
try
{
await context.Orders
.Where(o => o.Id == orderId)
.ExecuteUpdateAsync(setters =>
setters.SetProperty(o => o.Status, "Completed")
);
await context.Products
.Where(p => p.Id == productId)
.ExecuteUpdateAsync(setters =>
setters.SetProperty(p => p.Stock, p => p.Stock - quantity)
);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
This ensures that both updates participate in the same transaction.
Use explicit transactions when the business operation requires atomicity. Avoid unnecessarily large transactions, especially when updating thousands of records, because they can increase lock duration and database resource usage.
7. Implement Concurrency Checks Explicitly
When you use SaveChangesAsync with configured concurrency tokens, EF Core automatically includes the concurrency token in the update condition.
ExecuteUpdateAsync does not automatically apply the same concurrency checks because it bypasses the change tracker.
You can implement optimistic concurrency by including the expected token in the query condition.
For example, assume an employee table contains a Version column that acts as a concurrency token:
var affectedRows = await context.Employees
.Where(e => e.Id == employeeId
&& e.Version == expectedVersion)
.ExecuteUpdateAsync(setters => setters
.SetProperty(e => e.Salary, 80000)
);
if (affectedRows == 0)
{
throw new InvalidOperationException(
"The employee record was modified by another operation."
);
}
If another operation has already changed the version, the condition will not match and no row will be updated.
The application must also ensure that the concurrency token is advanced appropriately. For example, a SQL Server rowversion column is maintained by the database, while an application-managed token needs to be updated as part of the operation.
This is particularly important in financial, inventory, and order management applications where concurrent updates can affect business correctness.
8. When Should You Still Load Entities?
ExecuteUpdateAsync is not a replacement for every update scenario.
Sometimes the application needs to inspect each entity, apply complex business rules, or calculate values that cannot be expressed through a supported database operation.
For example, suppose a system calculates employee bonuses based on multiple business rules, external service responses, and application-specific logic.
In that situation, loading entities and updating them through the change tracker may be appropriate.
You can still control memory usage by processing records in smaller batches:
const int batchSize = 500;
var lastProcessedId = 0;
while (true)
{
var employees = await context.Employees
.Where(e => e.Id > lastProcessedId)
.OrderBy(e => e.Id)
.Take(batchSize)
.ToListAsync();
if (employees.Count == 0)
{
break;
}
foreach (var employee in employees)
{
employee.Bonus = CalculateBonus(employee);
}
await context.SaveChangesAsync();
lastProcessedId = employees[^1].Id;
context.ChangeTracker.Clear();
}
This example uses keyset-style pagination to process a limited number of records at a time.
ChangeTracker.Clear removes tracked entities after each batch, preventing the DbContext from retaining every processed entity.
The CalculateBonus method represents application-specific business logic.
This approach is useful when updates require individual entity processing, but it involves more database and application work than a direct bulk update.
9. ExecuteUpdateAsync vs SaveChangesAsync
Both approaches have valid use cases. The important part is choosing the one that matches the operation.
Area | ExecuteUpdateAsync | SaveChangesAsync |
|---|
Loads entities | No | Usually, when modifying existing entities |
Change tracking | Bypassed | Uses change tracking |
Bulk updates | Suitable for set-based updates | Can update multiple tracked entities |
Database commands | Usually one update statement per call | Generates statements for changed entities |
Concurrency handling | Must be implemented explicitly | Supports configured concurrency tokens |
Complex business logic | Limited to translatable database operations | Suitable for application-side processing |
Transactions | Explicit transaction for multiple operations | Uses a transaction for the save operation when supported |
Entity state | Does not synchronize tracked entities | Keeps tracked entities synchronized |
Use ExecuteUpdateAsync when the same database operation can be applied to many records.
Use SaveChangesAsync when the application needs to load entities, evaluate business rules, and modify individual properties.
10. Common Mistakes to Avoid
When implementing large updates in EF Core, keep these points in mind:
Loading all records unnecessarily: Avoid calling ToListAsync before a bulk update when the operation can be expressed directly through ExecuteUpdateAsync.
Mixing tracked and untracked updates: Remember that ExecuteUpdateAsync does not synchronize entities already tracked by the DbContext.
Ignoring transaction boundaries: Multiple ExecuteUpdateAsync calls are separate operations unless you explicitly create a transaction.
Skipping concurrency checks: If concurrent modifications matter, include the expected concurrency token in the update condition and check the affected row count.
Choosing an arbitrary batch size: Test batch sizes against realistic data volumes and database workloads.
Using long-running transactions: Large transactions can increase locking and transaction log pressure. Keep batch transactions short when the business requirements allow it.
Forgetting database indexes: Conditions used to identify records should be supported by appropriate indexes where beneficial. Otherwise, the database may need to scan a large portion of the table.
11. Best Practices for Production Applications
For production systems, efficient updates require more than choosing the right EF Core method.
Consider the following practices:
Prefer ExecuteUpdateAsync for set-based updates that can be expressed through LINQ.
Filter records carefully so the database modifies only the intended rows.
Use batching when a single large update creates unacceptable locking or transaction log pressure.
Use explicit transactions when several updates must succeed together.
Implement concurrency checks where simultaneous modifications are possible.
Monitor query execution plans, database locks, transaction log usage, and execution time.
Use a separate DbContext or reload tracked entities when mixing direct updates with tracked operations.
Test updates with production-like data volumes before deploying them.
Most importantly, measure the actual database workload. A single SQL statement is not automatically harmless simply because it uses fewer roundtrips. An update affecting millions of rows can still be expensive.
Summary
Updating thousands of records in EF Core does not always require loading every entity into application memory. ExecuteUpdateAsync provides a direct way to perform set-based updates in the database without involving the change tracker.
For straightforward operations, it can reduce memory consumption, application-side processing, and database roundtrips. When the update affects a very large dataset, smaller batches can provide better operational control. For complex business rules, loading entities in controlled batches remains a valid option.
The key is to understand the difference between direct database updates and tracked entity updates. Choose ExecuteUpdateAsync for operations that belong in the database, use SaveChangesAsync when entity-level processing is necessary, and handle transactions and concurrency explicitly when the business operation requires them.
Join the conversation! Your thoughts help the community grow.