What You Need To Know About EF Core Bulk Updates

What You Need To Know About EF Core Bulk Updates

5 min read··

databaseef-coreperformancetransactions

EF Core 7 introduced ExecuteUpdate and ExecuteDelete, which translate a bulk operation directly into a single SQL UPDATE or DELETE statement. That gives them significant performance advantages over loading entities and calling SaveChanges. The important caveat is that they bypass the EF Core Change Tracker, so entities already loaded in memory keep their old values.

EF Core is too slow? Discover how you can easily insert 14x faster (reducing saving time by 94%). Boost your performance with our method integrated within EF Core: Bulk Insert, update, delete, and merge. Join 5,000+ satisfied customers who have trusted our library since 2014. Click here to learn more.

Free eBook: Blazor Hybrid and Web in One Solution. Dive into Blazor cross-platform apps, potential roadblocks, testing with Razor components, and more. Get the eBook here!

When you're dealing with thousands or even millions of records, efficiency is king. That's where EF Core bulk update capabilities come into play.

EF Core 7 introduced two powerful new methods, ExecuteUpdate and ExecuteDelete. They're designed to simplify bulk updates in your database. Both methods have their respective async overloads - ExecuteUpdateAsync and ExecuteDeleteAsync. EF bulk updates offer significant performance advantages over traditional approaches.

However, there's an important caveat: these bulk operations bypass the EF Core Change Tracker. This disconnect can lead to unexpected behavior if you're not aware of it.

In this week's issue, we'll dive into the details of bulk updates in EF Core.

Understanding the EF Core ChangeTracker

When you load entities from the database with EF Core, the ChangeTracker starts tracking them. As you update properties, delete entities, or add new ones, the ChangeTracker records these changes.

using (var context = new AppDbContext())
{
    // Load a product
    var product = context.Products.FirstOrDefault(p => p.Id == 1);
    product.Price = 99.99; // Modify a property

    // At this point, the ChangeTracker knows that 'product' has been modified

    // Add a new product
    var newProduct = new Product { Name = "New Gadget", Price = 129.99 };
    context.Products.Add(newProduct);

    // Delete a product
    context.Products.Remove(product);

    context.SaveChanges(); // Persist all changes to the database
}

When you call SaveChanges, EF Core uses the ChangeTracker to determine which SQL commands to execute. This ensures that the database is perfectly synchronized with your modifications. The ChangeTracker acts as a bridge between your in-memory object model and your database.

If you're already familiar with how EF Core works, this serves mostly as a reminder.

Bulk Updates and the ChangeTracker Disconnect

Now, let's focus on how bulk updates in EF Core interact with the ChangeTracker - or rather, how they don't interact with it. This design decision might seem counterintuitive, but there's a solid reason behind it: performance.

By directly executing SQL statements against the database, EF Core eliminates the overhead of tracking individual entity modifications.

using (var context = new AppDbContext())
{
    // Increase price of all electronics by 10%
    context.Products
        .Where(p => p.Category == "Electronics")
        .ExecuteUpdate(
            s => s.SetProperty(p => p.Price, p => p.Price * 1.10));

    // In-memory Product instances with Category == "Electronics"
    // will STILL have their old price
}

In this example, we're increasing the price of all products in the Electronics category by 10%. The ExecuteUpdate method efficiently translates the operation into a single SQL UPDATE statement.

UPDATE [p]
SET [p].[Price] = [p].[Price] * 1.10
FROM [Products] as [p];

However, if you inspect the Product instances that EF Core has already loaded into memory, you'll find that their Price properties haven't changed. This might seem surprising if you aren't aware of how bulk updates interact with the change tracker.

Everything we discussed up to this point also applies to the ExecuteDelete method.

EF Core interceptors do not trigger for ExecuteUpdate and ExecuteDelete operations. If you need to track or modify bulk update operations, you can create database triggers that fire whenever a relevant table is updated or deleted. This allows you to log details and perform additional actions.

The Problem: Maintaining Consistency

If ExecuteUpdate completes successfully, the changes are directly committed to the database. This is because bulk operations bypass the ChangeTracker and don't participate in the usual transaction managed by SaveChanges.

If SaveChanges subsequently fails due to an error (e.g., validation error, database constraint violation, connection issue), you'll be in an inconsistent state. The changes made by ExecuteUpdate are already persisted. Any changes made "in memory" are lost.

The most reliable way to ensure consistency is to wrap both ExecuteUpdate and the operations that lead to SaveChanges in a transaction:

using (var context = new AppDbContext())
using (var transaction = context.Database.BeginTransaction())
{
    try
    {
        context.Products
            .Where(p => p.Category == "Electronics")
            .ExecuteUpdate(
                s => s.SetProperty(p => p.Price, p => p.Price * 1.10));

        // ... other operations that modify entities

        context.SaveChanges();

        transaction.Commit();
    }
    catch (Exception ex)
    {
        // You could also let the transaction go out of scope.
        // This would automatically rollback any changes.
        transaction.Rollback();

        // Proceed to handle the exception...
    }
}

If SaveChanges fails, the transaction will be rolled back, reverting the changes made by both ExecuteUpdate and any other operations within the transaction. This keeps your database in a consistent state.

Summary

EF Core bulk update features, ExecuteUpdate and ExecuteDelete, are invaluable tools for optimizing performance. By bypassing the ChangeTracker and executing raw SQL directly, they deliver significant speed improvements compared to traditional methods.

However, it's crucial to be mindful of the potential pitfalls associated with this approach. The disconnect between in-memory entities and the database state can lead to unexpected results if not handled correctly.

My rule of thumb is to create an explicit database transaction when I want to make additional entity changes. We can be confident that all the changes will persist in the database or none of them will.

I hope this was helpful, and I'll see you next week.

P.S. Get the source code and try out the examples from this issue.


Frequently Asked Questions

What are ExecuteUpdate and ExecuteDelete in EF Core?

EF Core 7 added ExecuteUpdate and ExecuteDelete (plus async overloads) for bulk operations. They translate directly into a single SQL UPDATE or DELETE statement, which gives them significant performance advantages over loading entities and calling SaveChanges.

Why doesn't ExecuteUpdate update my tracked entities in EF Core?

ExecuteUpdate and ExecuteDelete bypass the EF Core Change Tracker by design, for performance. They run SQL directly against the database, so entities already loaded in memory keep their old values even after the bulk operation succeeds.

Do EF Core interceptors fire for ExecuteUpdate and ExecuteDelete?

No. EF Core interceptors do not trigger for ExecuteUpdate and ExecuteDelete operations. If you need to track or react to bulk updates, you can create database triggers on the affected tables and log details or perform additional actions there.

How do you keep the database consistent when combining ExecuteUpdate with SaveChanges?

Wrap both in an explicit database transaction. ExecuteUpdate commits its changes immediately and does not participate in the SaveChanges transaction, so if SaveChanges later fails you end up in an inconsistent state. A shared transaction rolls everything back together.

Are EF Core bulk updates faster than SaveChanges?

Yes. By bypassing the Change Tracker and executing a single SQL statement directly, ExecuteUpdate and ExecuteDelete eliminate the overhead of tracking individual entity modifications, which delivers significant speed improvements when updating thousands or millions of records.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.