Entity Framework Core 7 introduced two powerful methods for bulk operations:
ExecuteUpdateAsync and ExecuteDeleteAsync.
These methods are designed for high-performance scenarios where you need to update or delete multiple records efficiently without loading them into memory. They work great, but there's one caveat: EF Core's Change Tracker is not aware of these changes.
In this article, we explore how these methods work, why they bypass the Change Tracker, and most importantly—how you can audit or track changes manually when using them.
If you're not familiar with EF Core's Change Tracker, check out this introductory guide on Change Tracking to understand how EF Core monitors entity states.
Why Use ExecuteUpdateAsync and ExecuteDeleteAsync?
These methods:
- Generate direct SQL UPDATE and DELETE statements.
- Do not load entities into memory, improving performance.
- Bypass change tracking, which avoids unnecessary overhead.
Example Usage:
// Bulk update
await context.Users
.Where(u => u.LastLogin < DateTime.UtcNow.AddYears(-1))
.ExecuteUpdateAsync(setters => setters.SetProperty(u => u.IsInactive, true));
// Bulk delete
await context.Users
.Where(u => u.IsInactive)
.ExecuteDeleteAsync();
The Problem: No Change Tracking
Because EF Core does not load entities into memory during these operations, the Change Tracker never sees them. This means:
- No automatic change detection.
- No SaveChangesAsync() involvement.
- No triggers for audit or domain events.
So, how do you track what was changed or deleted?
1.Auditing Options for Bulk Operations
- Manual Pre-Query Before Execution
Fetch affected entities before performing the batch operation.
var affectedUsers = await context.Users
.Where(u => u.LastLogin < DateTime.UtcNow.AddYears(-1))
.Select(u => new { u.Id, u.Name, OldValue = u.IsInactive })
.ToListAsync();
await context.Users
.Where(u => u.LastLogin < DateTime.UtcNow.AddYears(-1))
.ExecuteUpdateAsync(setters => setters.SetProperty(u => u.IsInactive, true));
// Log changes manually
foreach (var user in affectedUsers)
{
Console.WriteLine($"User {user.Name} updated: IsInactive {user.OldValue} → true");
}
Pros:
Precise control over what you log.
Access to old values.
Cons:
Requires an extra query.
May impact performance for large datasets.
- Audit Table Logging
Save the affected data into an audit table before executing the bulk operation.
Example:
await context.AuditLogs.AddRangeAsync(affectedUsers.Select(user => new AuditLog
{
EntityName = "User",
EntityId = user.Id,
ChangeType = "Updated",
ChangedAt = DateTime.UtcNow,
Description = $"User {user.Name} was updated: IsInactive {user.OldValue} → true"
}));
await context.SaveChangesAsync();
- Database Triggers
Define SQL triggers to capture changes directly in the database.
Example :
CREATE TRIGGER trg_User_Delete
ON Users
AFTER DELETE
AS
BEGIN
INSERT INTO AuditLogs (EntityName, EntityId, ChangeType, ChangedAt, Description)
SELECT 'User', Id, 'Deleted', GETUTCDATE(), CONCAT('User ', Name, ' was deleted') FROM deleted;
END;
Pros:
Fully automated.
Ensures auditing even if EF Core is bypassed.
Cons:
Less flexible.
Harder to manage and version control.
- EF Core Interceptors (Advanced)
Capture executed SQL commands using DbCommandInterceptor.
Limitation:
You can log the SQL query but not the previous values of affected entities.
public class AuditInterceptor : DbCommandInterceptor
{
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
Console.WriteLine($"SQL Executed: {command.CommandText}");
return base.ReaderExecuting(command, eventData, result);
}
}
Registering the Interceptor :
public class ApplicationDbContext : DbContext
{
public DbSet<User> Users { get; set; }
public DbSet<AuditLog> AuditLogs { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("YourConnectionStringHere");
optionsBuilder.AddInterceptors(new AuditInterceptor());
}
Conclusion
EF Core's ExecuteUpdateAsync and ExecuteDeleteAsync offer incredible performance for bulk data operations but come at the cost of losing automatic change tracking.
If you need to audit or track changes, the best approach is to manually query the affected data before execution or use a dedicated audit system such as audit tables or SQL triggers.
Choose the method that balances performance and auditing needs for your application.
What do you think? Do you prefer performance with manual tracking, or do you rely on EF Core's change tracking? Let us know your approach!
You can find the full code example at: https://github.com/stevsharp/EfCoreExamples/tree/BulkUpdateAndDeletes
References
Microsoft Docs - EF Core 7: ExecuteUpdate and ExecuteDelete
Microsoft Docs - Change Tracking in EF Core
Microsoft Docs - Interceptors in EF Core
Community Discussion - EF Core GitHub Issue on Change Tracking and Bulk Operations