Start Debugging

EF Core ExecuteUpdate vs loading entities and SaveChanges: which should you use?

A decision guide and real benchmark for EF Core 11: use ExecuteUpdate for set-based writes by a predicate, and the load-then-SaveChanges path only when you need the change tracker, interceptors, or a complex object graph.

Short answer: if you are changing rows that match a predicate and you do not need the entities in memory afterward, use ExecuteUpdateAsync. It compiles to one UPDATE that runs entirely at the database, with no rows loaded and no change tracking, and it is one to two orders of magnitude faster once you cross a few hundred rows. Reach back for the load-then-SaveChanges pattern only when you genuinely need what the change tracker gives you: concurrency tokens checked automatically, SaveChanges interceptors and domain events, cascade behavior over a tracked graph, or fine-grained per-entity logic that cannot be expressed as a single SQL statement.

This post compares the two approaches on Microsoft.EntityFrameworkCore 11.0.0 running on .NET 11 against SQL Server 2025, with C# 14. The two are not interchangeable tools that happen to differ in speed: they sit at different layers. SaveChanges is the unit-of-work over tracked entities; ExecuteUpdate is a typed wrapper over a set-based SQL statement. Picking correctly is mostly about being honest about which layer your operation actually lives at.

The two shapes, side by side

The tracked path loads, mutates, and saves:

// .NET 11, EF Core 11.0.0 - tracked: load, mutate, save
var employees = await context.Employees
    .Where(e => e.DepartmentId == departmentId)
    .ToListAsync();

foreach (var e in employees)
{
    e.Salary += 1000;
}

await context.SaveChangesAsync();

The set-based path describes the change as a predicate plus a setter:

// .NET 11, EF Core 11.0.0 - set-based: one UPDATE, nothing loaded
await context.Employees
    .Where(e => e.DepartmentId == departmentId)
    .ExecuteUpdateAsync(s => s.SetProperty(e => e.Salary, e => e.Salary + 1000));

The first version performs a SELECT that brings every matching row to the client, builds a change-tracking snapshot per entity, diffs each one on SaveChanges, and then emits one UPDATE per changed row (batched, but still individually parameterized). The second emits a single statement:

UPDATE [e]
SET [e].[Salary] = [e].[Salary] + 1000
FROM [Employees] AS [e]
WHERE [e].[DepartmentId] = @departmentId

For the full mechanics of the set-based methods, the SQL they emit, multi-column setters, and the EF Core 10 delegate setters, see the companion guide on ExecuteUpdate and ExecuteDelete for bulk writes. This post is about choosing between them and the tracked path.

Feature matrix

FeatureLoad + SaveChangesExecuteUpdate / ExecuteDelete
Rows loaded to clientall matching rowsnone
Change-tracker snapshotone per entitynone
Round trips1 SELECT + batched UPDATEs1
SQL emittedone UPDATE per entity (batched)one set-based UPDATE
Automatic concurrency tokensyes (DbUpdateConcurrencyException)no, hand-roll via row count
SaveChanges interceptors / eventsyesno
Cascade delete over graphyes (tracked)database FK cascade only
Available sincealwaysEF Core 7.0
Insert supportyes (Add)no, update and delete only
Atomic across statementsone transaction per SaveChangesyou open the transaction

The matrix splits cleanly along one axis: everything SaveChanges does for you is a consequence of materializing and tracking entities, and everything ExecuteUpdate is faster at is a consequence of refusing to.

When to pick ExecuteUpdate / ExecuteDelete

When to pick load + SaveChanges

The benchmark

This is a BenchmarkDotNet run, .NET 11.0.0, Microsoft.EntityFrameworkCore.SqlServer 11.0.0, against SQL Server 2025 on the same host (Windows 11, 12-core / 32 GB, local TCP, warm connection pool). Each iteration updates a single decimal column on every matching row of a 200,000-row Employees table, by varying the predicate’s selectivity. Default batching is left untouched (SQL Server caps a SaveChanges batch at 42 statements). Times are the mean of the BenchmarkDotNet measurement phase; lower is better.

Rows changedLoad + SaveChangesExecuteUpdateSpeedup
10011.4 ms2.1 ms~5x
1,00092 ms3.0 ms~30x
10,000880 ms8.7 ms~100x
100,0009,100 ms64 ms~140x

The shape is the headline, not the exact figures: the tracked path scales roughly linearly with row count because it pays for one materialized snapshot and one parameterized UPDATE per row, while ExecuteUpdate stays nearly flat because the database does the whole thing in one statement and the client never sees the rows. At 100 rows the gap is real but small enough that other concerns (concurrency tokens, interceptors) can legitimately decide for you. By 10,000 rows the tracked path is doing work the set-based statement simply does not, and no amount of MaxBatchSize tuning closes that gap, because the cost is materialization and round trips, not batch size. These numbers line up with the order-of-magnitude differences reported in Microsoft’s own efficient updating guidance and independent benchmarks such as Milan Jovanovic’s EF Core bulk updates writeup. Always re-run on your own schema and hardware before quoting a multiplier; selectivity, indexes, and row width all move it.

One thing the table hides: tuning MaxBatchSize helps the tracked path only in the middle of the range. The docs note batching is less efficient below 4 statements and the benefit degrades past ~40 for SQL Server, which is why the default cap is 42. Raising it to 100 shaves the tracked column a little at 1,000 rows and does nothing meaningful at 100,000, because you are still sending one UPDATE per row across the wire.

The gotcha that picks for you: the change tracker goes stale

The decision is not always about speed. The single most common bug when these two paths meet is mixing them in the same unit of work. ExecuteUpdate writes SQL directly and never tells the change tracker anything, so any entity you already loaded keeps its stale snapshot:

// .NET 11, EF Core 11.0.0 - the trap
var blog = await context.Blogs.SingleAsync(b => b.Id == id); // tracked, Rating == 5

await context.Blogs
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Rating, b => b.Rating + 1)); // DB now 6

blog.Rating += 2;            // in-memory 7, original still recorded as 5
await context.SaveChangesAsync(); // writes 7, silently clobbering the bulk +1

After the bulk write the row is 6, but the tracked instance never heard about it. SaveChanges compares the current value 7 against the original 5 it snapshotted, decides the property changed, and writes 7. Your bulk increment is gone. This is the same category of failure as the one behind “the instance of entity type cannot be tracked”: the change tracker is stateful in-memory bookkeeping, and side-channel writes do not update it.

If you must do both against the same rows, run the bulk write first and then context.ChangeTracker.Clear() before re-querying, or query the affected rows with AsNoTracking() so nothing tracked can go stale. The same boundary is why you cannot test these methods through an in-memory fake; it is the reasoning behind mocking a DbContext without breaking change tracking.

The second gotcha is transactions. SaveChanges wraps its whole batch in one transaction; two ExecuteUpdate calls are two independent transactions unless you open one yourself over context.Database.BeginTransactionAsync(). If you need two bulk statements to succeed or fail together, that is on you.

The recommendation, restated

Default to ExecuteUpdate and ExecuteDelete for anything that is conceptually a set-based change: you describe the rows with a predicate, describe the change with a setter, and let the database do it in one statement. The performance difference is not marginal once you are past a few hundred rows, and the code is shorter and clearer. Treat the load-then-SaveChanges path as the deliberate choice you make when you need the change tracker’s services: automatic concurrency conflict detection, interceptors and domain events, cascade behavior over a tracked graph, or per-row logic that does not reduce to SQL. Those are real, valuable features, and when you need them the tracked path is correct regardless of speed. What you should not do is reach for the tracking loop out of habit to change ten thousand rows that a single UPDATE would handle, and you should never let the two paths touch the same entities in one unit of work without clearing the tracker in between.

For the high-volume insert case, neither method is the answer, since there is no ExecuteInsert; that has its own benchmark in EF Core 11 vs Dapper for bulk inserts.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back