EF Core Bulk Updates and Deletes: Performance vs Change Tracking

posted 3 min read

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:

  1. Generate direct SQL UPDATE and DELETE statements.
  2. Do not load entities into memory, improving performance.
  3. 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:

  1. No automatic change detection.
  2. No SaveChangesAsync() involvement.
  3. No triggers for audit or domain events.

So, how do you track what was changed or deleted?

1.Auditing Options for Bulk Operations

  1. 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.

  1. 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();
  1. 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.

  1. 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

If you read this far, tweet to the author to show them you care. Tweet a Thanks

Great read! The performance gain is huge, but how do you handle auditing in high-traffic apps? Appreciate the effort!

Thanks! Glad you enjoyed the read!
In high-traffic applications, a few helpful approaches include Asynchronous Logging, Audit Database Separation, or Structured Logging using Serilog or NLog.
Let me know if you’d like me to dive deeper into any of these!

More Posts

EF Core: Lazy Loading, Eager Loading, and Loading Data on Demand

Spyros - Mar 6

Optimistic vs. Pessimistic Concurrency in EF Core (with Table Hints)

Spyros - Mar 31

EF Core Global Query Filters: A Complete Guide

Spyros - Mar 2

When NOT to Use AsSplitQuery() in EF.core

Spyros - Feb 26

EF Core Interceptors: Supercharge Your Data Access with the Decorator Pattern

Spyros - Mar 20
chevron_left