In this article, we are going to talk about how to use bulk updates in Entity Framework Core. If you are interested in a deeper dive into how to use Entity Framework Core, we have a whole series about that.

To download the source code for this article, you can visit our GitHub repository.

Let’s see how we can do bulk updates using Entity Framework Core bulk operations.

Prepare the Entity Model

For this article, we are going to use the entity model to demonstrate the usage of the new bulk updates in Entity Framework Core. Therefore, we have a Team entity:

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!
public class Team
{
    [Key]
    public Guid Id { get; set; }

    [Required]
    public string Name { get; set; }

    [Required]
    public int YearFounded { get; set; }

    [Required]
    public string Description { get; set; }

    public ICollection<Player> Players { get; set; }

    public ICollection<Game> Games { get; set; }
}

And every team has some players in it:

public class Player
{
    [Key]
    public Guid Id { get; set; }

    [Required]
    public string Name { get; set; }

    [Required]
    public int Age { get; set; }

    [Required]
    public Guid TeamId { get; set; }        
   
    [ForeignKey(nameof(TeamId))]
    public virtual Team Team { get; set; }
}

And it also has some games:

public class Game
{
    [Key]
    public Guid Id { get; set; }

    [Required]
    public string Opponent { get; set; }

    [Required]
    public DateTime Date { get; set; }

    [Required]
    public Guid TeamId { get; set; }

    [Required]
    public virtual Team Team { get; set; }
}

This represents the baseline of our entity model which we are going to use throughout the rest of the article.

The Conventional SaveChanges Method

Now, that we have prepared our data model, let’s assume we have some Team records stored in our database and want to bulk update/delete them using Entity Framework Core.

One conventional way is to load all of the records and make desired updates to them through a loop and call _context.SaveChanges() method. Then, the change tracker identifies the modification of entities and performs several SQL updates to SQL Server to make updates to all the records.

Let’s have a look at the code:

var teams = await _context.Teams
    .ToListAsync();

foreach (var team in teams)
{
    team.YearFounded += 1;
    team.Description += " " + description;
}

return await _context.SaveChangesAsync();

Here, we increase the YearFounded property of all the team records by 1 and concatenate a description to the Description property.

If for example, we have 4 records and MaxBatchSize set to 2, as soon as we call _context.SaveChanges() method, this translates into two SQL executions.

Let’s examine the first one:

Executed DbCommand
[Parameters=[@p2='4a211dd7-e2f6-4023-ac83-bd0bd303fec9', @p0='Description 3 for Code Maze Newly Added' 
(Nullable = false) (Size = 4000), @p1='1992', @p5='4dfbf011-d4f3-4dff-a5e2-3bd3d5c28d76',
@p3='Description 0 for Code Maze Newly Added' (Nullable = false) (Size = 4000),
@p4='1994'], CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
UPDATE [Teams] 
SET [Description] = @p0, 
    [YearFounded] = @p1
OUTPUT 1
WHERE [Id] = @p2;
UPDATE [Teams] 
SET [Description] = @p3,
    [YearFounded] = @p4
OUTPUT 1
WHERE [Id] = @p5;

And the second one:

Executed DbCommand 
[Parameters=[@p2='537072e6-e002-4b9c-a9e4-8ad8e83fa4ac', @p0='Description 1 for Code Maze Newly Added' 
(Nullable = false) (Size = 4000), @p1='2020', @p5='9396739b-05b4-42fb-84f9-6ed7fa3b64db', 
@p3='Description 2 for Code Maze Newly Added' (Nullable = false) (Size = 4000), @p4='1983'], 
CommandType='Text', CommandTimeout='30'] 
SET NOCOUNT ON; 
UPDATE [Teams]
SET [Description] = @p0,
    [YearFounded] = @p1 
OUTPUT 1 
WHERE [Id] = @p2;
UPDATE [Teams] 
SET [Description] = @p3, 
    [YearFounded] = @p4 
OUTPUT 1 
WHERE [Id] = @p5;

These SQL executions indicate that two sequential batch updates have been performed on our database. The default MaxBatchSize in EF Core is 42. It means that if we have 100 records and we want to update all of them, we need to make [100 /42]+1 =3 roundtrips to the database to complete the task.

Depending on the MaxBatchSize setting and the number of records = n, we need to perform ceil(n/MaxBatchSize) roundtrips to the database to update all the data.

Now, let’s see the code for conventional bulk delete:

var teams = await _context.Teams
    .ToListAsync();

_context.RemoveRange(teams);

return await _context.SaveChangesAsync();

The same pattern and formula apply with bulk delete. Again, we have n=4 records and MaxBatchSize = 2. As soon as we call _context.SaveChanges() method, this translates to the two SQL executions:

Executed DbCommand
[Parameters=[@p0='00b31bff-84b6-4935-9306-d952face0fcd', @p1='5b10d33d-c2de-4ec1-9e35-7460fc83ebdc'], 
CommandType='Text', CommandTimeout='30']
SET NOCOUNT ON;
DELETE FROM [Teams]
OUTPUT 1
WHERE [Id] = @p0;
DELETE FROM [Teams]
OUTPUT 1
WHERE [Id] = @p1;

And the second one:

Executed DbCommand 
[Parameters=[@p0='791a41ce-4ad4-4f34-a708-54df926c1726', @p1='a467a4f0-5406-4cfa-a726-3682cd9f3437'], 
CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; 
DELETE 
FROM [Teams] 
OUTPUT 1 
WHERE [Id] = @p0; 
DELETE 
FROM [Teams] 
OUTPUT 1 
WHERE [Id] = @p1;

The New Bulk Updates In Entity Framework Core

Microsoft introduced two new bulk update methods since Entity Framework Core 7: ExecuteUpdate and ExecuteDelete. With these two methods, we no longer need to load all the entities into memory to update their properties or to delete them all. We just need to tell the method to update the desired properties or to delete all of the records and then it takes care of the rest of it. Therefore, we no longer have a change tracker involved and the whole bulk update gets executed in only one database roundtrip regardless of the value of MaxBatchSize.

ExecuteUpdate Method

Now, we want to use the ExecuteUpdate method to update four Team records:

Name    YearFounded    Description
------------------------------------------------------
Team 3    2004    Description 3 for Code Maze
Team 2    1998    Description 2 for Code Maze
Team 0    1974    Description 0 for Code Maze
Team 1    2005    Description 1 for Code Maze

 Let’s write the code:

await _context.Teams
    .ExecuteUpdateAsync(t =>
       t.SetProperty(b => b.YearFounded, b => b.YearFounded + 1)
        .SetProperty(b => b.Description, b => b.Description + " " + description));

Here, we use SetProperty to explicitly set the desired value of each property.

Again, we can inspect the translated SQL script in the Visual Studio output window:

Executed DbCommand 
[Parameters=[@__description_0='Newly Added' (Size = 4000)], CommandType='Text', CommandTimeout='30']
UPDATE [t]
SET [t].[Description] = ([t].[Description] + N' ') + @__description_0,
    [t].[YearFounded] = [t].[YearFounded] + 1
FROM [Teams] AS [t]

We can observe that the whole bulk update executes in only one database roundtrip.

Next, we can fetch all the records to inspect the changes:

return await _context.Teams
    .ToListAsync();

Once we execute it, we can find the result:

Name    YearFounded    Description
------------------------------------------------------
Team 3    2004    Description 3 for Code Maze
Team 2    1998    Description 2 for Code Maze
Team 0    1974    Description 0 for Code Maze
Team 1    2005    Description 1 for Code Maze

As we can see, the data has not changed. What is the problem?

Well, as we said, we no longer have the change tracker involved when we use ExecuteUpdate method. It means that after executing ExecuteUpdate, the change tracker is not aware of the modification to the records; therefore, when we call _context.Teams.ToListAsync(), it uses the cached values and returns them to us. So, what should we do?

We should modify the code and add AsNoTracking() to it so that the new values are fetched from the database:

return await _context.Teams
    .AsNoTracking()
    .ToListAsync();

Again, let’s inspect the records:

Name    YearFounded    Description
------------------------------------------------------
Team 3    2005    Description 3 for Code Maze Newly Added
Team 2    1999    Description 2 for Code Maze Newly Added
Team 0    1975    Description 0 for Code Maze Newly Added
Team 1    2006    Description 1 for Code Maze Newly Added

Now, we can see that YearFounded and Description properties of all records have changed accordingly.

ExecuteDelete Method

Now, it’s time to use ExecuteDelete to delete all the records that satisfy a desired condition.

For example, we want to delete all the Team records that have YearFounded greater than a specified minYearFounded value:

await _context.Teams
   .Where(t => t.YearFounded >= minYearFounded)
   .ExecuteDeleteAsync();

Next, let’s inspect the output window:

Executed DbCommand
[Parameters=[@__minYearFounded_0='1970'], CommandType='Text', CommandTimeout='30']
DELETE FROM [t]
FROM [Teams] AS [t]
WHERE [t].[YearFounded] >= @__minYearFounded_0

Again, the system executes the bulk delete in only one database roundtrip no matter the value of MaxBatchSize and the change tracker remains unaware of the deletion operation. Therefore, if we want to get the Team records after the delete, we should use AsNoTracking().

Moreover, in cases where there are a principal entity and several dependent entities, such as the Team entity and the Player entity in this example, we should handle delete constraints (CascadeDelete, SetNull, NoAction) the same way we handle normal delete operations.

Handle Inheritance Strategies With Bulk Updates In Entity Framework Core

When we want to use the two new bulk update methods ExecuteUpdate and ExecuteDelete to update data mapped from inherited entities, we should remember one simple rule: We cannot use these bulk update methods to act on multiple tables; therefore, if we have table hierarchies using TPH inheritance strategy, we will have no issues because the bulk update methods will act only on one common table.

Another example is when we have TPC inheritance strategy. To demonstrate this, let’s assume we have an Employee abstract class:

public abstract class Employee
{
    [Key]
    public Guid Id { get; set; }
    
    [Required]
    public string FirstName { get; set; }

    [Required]
    public string LastName { get; set; }

    [Required]
    public int YearsExperience { get; set; }
}

There is a Referee entity that inherits Employee class:

public class Referee : Employee 
{
    [Required]
    public string RefereeCode { get; set; }

    [Required]
    public string LicenceNo { get; set; }

    [Required]
    public string Category { get; set; }
}

And also an Organizer entity that inherits Employee class as well:

public class Organizer : Employee
{
    [Required]
    public string OrganizerCode { get; set; }

    [Required]
    public int NoOfEventsOrganized { get; set; }
}

We use the TPC inheritance mapping strategy; therefore, it means we have Organizer and Referee tables in our database but we do not have a separate table for the Employee abstract class. Now, let’s delete Referee records:

return await _context.Set<Referee>()
    .ExecuteDeleteAsync();

We can see that this translates to:

DELETE FROM [r]
FROM [Referees] AS [r]

And after we execute it, the Referee records get deleted successfully.

Let’s consider another scenario where we have a Table Per Concrete Type (TPC) inheritance strategy.

In this scenario, we need to delete or update employees with experience exceeding a certain number of years:

await _context.Employees
    .Where(p => p.YearsExperience > minYearExperience)
    .ExecuteDeleteAsync();

In this situation, since there are multiple concrete tables (Referees and Organizers) inherited from Employee entity, this returns a System.InvalidOperationException.

To demonstrate TPT inheritance strategy, let’s assume that we have one type of game: FootballGame and it inherits Game class that we earlier defined:

public class FootballGame : Game
{
    public int FirstHalfTimeScore { get; set; }

    public int SecondHalfTimeScore { get; set; }
}

To delete all FootballGame records, we execute the code:

await _context.Games
  .OfType<FootballGame>()
  .ExecuteDeleteAsync();

We get a System.InvalidOperationException:

The LINQ expression 'DbSet<Game>()
    .OfType<FootballGame>()
    .ExecuteDelete()' could not be translated. Additional information: The operation 
'ExecuteDelete' is being applied on entity type 'FootballGame', which is using the 
TPT mapping strategy. 'ExecuteDelete'/'ExecuteUpdate' operations on hierarchies mapped as TPT 
is not supported. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

This exception also indicates that the same thing happens if we want to use ExecuteUpdate method while we have a TPT mapping strategy. The reason is simple: In the TPT inheritance strategy we keep data for the inherited types (e.g FootballGame) in two distinct database tables, Games and FootballGames in this case. Therefore, the LINQ expression cannot execute a bulk update on the two database tables and returns an exception instead.

Conclusion

In this article, we talked about how to use bulk updates in Entity Framework Core and  provided some examples to demonstrate how to use them both with conventional SaveChanges method and with the new bulk update methods (ExecuteUpdate and ExecuteDelete) introduced in Entity Framework Core 7. 

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!