← Open in the full interactive course (progress tracking, search & more)

Ten thousand tracked entities means ten thousand INSERT statements. EF Core 7 gave you a way to update or delete a million rows with exactly one.

Picture a routine cleanup job: mark every order older than two years as archived. You reach for the pattern you already know cold — load the entities, change a property, save:

List<Order> oldOrders = await context.Orders .Where(o => o.CreatedAt < cutoff) .ToListAsync(); foreach (Order order in oldOrders) order.IsArchived = true; await context.SaveChangesAsync();

This is completely correct, idiomatic EF Core — and if oldOrders has 40,000 rows in it, it's also quietly expensive in a way that has nothing to do with a bug in your code. Every one of those 40,000 Order objects gets fully materialized into memory. The change tracker starts watching all 40,000 of them. And when SaveChangesAsync runs, EF Core doesn't send one UPDATE statement covering all matching rows — it sends 40,000 individual UPDATE statements, one per tracked, modified entity, each its own round trip through the change-tracking and command-generation pipeline.

In this lesson, you'll learn exactly why that per-entity pattern gets expensive at scale, how EF Core 7+'s ExecuteUpdate and ExecuteDelete solve it with a single set-based SQL statement that bypasses change tracking entirely, why EF Core still has no built-in bulk-insert equivalent, and when reaching for any of this is actually worth it.

What Is It?

The Simple Explanation

A bulk operation is a change applied to many rows at once, in a single database round trip, without EF Core loading each affected row into memory as a tracked entity first. Instead of "fetch 40,000 objects, change each one, save each one individually," it's "tell the database, in one statement, exactly which rows to change and how" — and let the database do the actual work, in one pass, on its own side of the connection.

The Technical Definition

EF Core 7 introduced two LINQ methods purpose-built for this: ExecuteUpdate and ExecuteDelete (with Async variants), callable directly on an IQueryable<T>. Both translate the entire operation — the filter and, for updates, the new values — into one server-side SQL UPDATE or DELETE statement with a matching WHERE clause. Neither one loads matching entities into the change tracker at any point; the database applies the change directly to every matching row, and EF Core's job is limited to translating your LINQ into that one SQL statement and reporting back how many rows it affected.

Why Does It Exist?

The Problem — the Tracked-Entity Path Pays a Real Cost, Per Row, at Scale

The load-modify-save pattern is the right default for everyday CRUD — one user editing one order, one form submission updating one record. It stops being the right default the moment "one" becomes "tens of thousands." Three separate costs stack up: materializing every row into a full C# object costs memory and CPU; the change tracker snapshotting and diffing every one of those objects costs more CPU; and — the part that usually dominates — SaveChangesAsync issuing one UPDATE statement per changed entity means one database round trip per row, even when every row is being changed the exact same way. None of that overhead is a bug. It's the necessary cost of a mechanism designed around "track individual object graphs precisely," applied to a job that never needed object-level precision in the first place.

The Solution — Skip Loading Entirely, Let the Database Apply the Change Set-Based

ExecuteUpdate/ExecuteDelete exist because "change every row matching this filter, the same way" doesn't need per-row object tracking at all — it's exactly the shape of a plain SQL UPDATE ... WHERE or DELETE ... WHERE statement, which relational databases have always been able to execute as one set-based operation, entirely on the server, no matter how many rows it touches. EF Core 7 gave you a LINQ-native way to express that shape directly, instead of forcing every bulk change through the tracked-entity pipeline it was never designed for.

Big Picture

SAME GOAL, TWO COMPLETELY DIFFERENT EXECUTION PATHS
Tracked-entity path — Where().ToListAsync() → modify in a loop → SaveChangesAsync()
Bulk path — Where().ExecuteUpdateAsync(...)

Same end result on the data. Radically different cost profile getting there — and the gap widens as N grows.

How It Works

ExecuteUpdate — Setting New Values Directly in SQL

await context.Orders .Where(o => o.CreatedAt < cutoff) .ExecuteUpdateAsync(setters => setters .SetProperty(o => o.IsArchived, o => true));

Read this left to right: filter to the rows you want (Where), then describe the change with SetProperty — the first argument names the property being changed, the second is an expression for its new value (a constant, or computed from the row's own current values). EF Core translates the whole thing into one UPDATE Orders SET IsArchived = 1 WHERE CreatedAt < @cutoff. You can chain multiple SetProperty calls to update several columns in the same single statement:

await context.Products .Where(p => p.CategoryId == discontinuedCategoryId) .ExecuteUpdateAsync(setters => setters .SetProperty(p => p.IsDiscontinued, p => true) .SetProperty(p => p.DiscontinuedAt, p => DateTime.UtcNow));

ExecuteDelete — Deleting Every Matching Row in One Statement

int rowsDeleted = await context.Orders .Where(o => o.Status == OrderStatus.Cancelled && o.CreatedAt < cutoff) .ExecuteDeleteAsync();

No loading, no change tracking, no SaveChangesAsync() call at all — ExecuteDeleteAsync translates directly into DELETE FROM Orders WHERE Status = @status AND CreatedAt < @cutoff and returns the count of rows actually removed.

The One Gap — No Built-In ExecuteInsert

EF Core has no equivalent bulk-insert LINQ method — there's no ExecuteInsert. That gap makes sense once you notice the shape difference: ExecuteUpdate/ExecuteDelete both start from an existing IQueryable<T> — rows that already exist in the table, matched by a filter. A bulk insert has no such starting query to filter; it's fundamentally "take this in-memory collection of new rows and get all of them into the table efficiently," which is a different problem EF Core's own APIs don't currently address directly. For genuinely high-volume inserts — seeding, imports, migrations — the common real answer is a dedicated third-party library, most notably EFCore.BulkExtensions, which layers efficient bulk-insert (and bulk-update/delete) support on top of your existing DbContext and entity model.

Simple Example — Before and After, Side by Side

Tracked-entity update — N round trips

List<Product> products = await context.Products .Where(p => p.CategoryId == oldCategoryId) .ToListAsync(); foreach (Product product in products) product.CategoryId = newCategoryId; await context.SaveChangesAsync(); // If 15,000 products match: 1 SELECT + 15,000 individual UPDATE statements

ExecuteUpdate — 1 round trip, regardless of row count

int rowsUpdated = await context.Products .Where(p => p.CategoryId == oldCategoryId) .ExecuteUpdateAsync(setters => setters .SetProperty(p => p.CategoryId, p => newCategoryId)); // Exactly 1 UPDATE statement, no matter whether it matches 15 rows or 1.5 million

Same end state in the Products table either way. The difference is entirely in how much work your application and the database did to get there.

Real-World Example — a Nightly Cleanup Job

A background service that runs every night to purge stale, never-completed shopping carts:

public class CartCleanupService(AppDbContext context, ILogger<CartCleanupService> logger) { public async Task PurgeAbandonedCartsAsync(CancellationToken cancellationToken) { DateTime cutoff = DateTime.UtcNow.AddDays(-30); int deleted = await context.ShoppingCarts .Where(c => c.Status == CartStatus.Abandoned && c.LastUpdatedAt < cutoff) .ExecuteDeleteAsync(cancellationToken); logger.LogInformation("Purged {Count} abandoned carts older than {Cutoff}", deleted, cutoff); } }

This job might delete a few dozen rows on a quiet night, or a few hundred thousand after a slow month. Either way, it's one DELETE statement, one round trip, and no risk of the job running out of application memory trying to materialize every abandoned cart first. This is exactly the shape of job — a scheduled cleanup, a migration step, a mass status change — where bulk operations are the obviously right tool, not a premature optimization.

Analogy

A Mail Merge Letter, Not 10,000 Individually Opened Envelopes

The tracked-entity path is like opening every one of 10,000 envelopes, reading the letter inside, crossing out one line by hand, and resealing each envelope individually — accurate, but the cost scales directly with the number of envelopes, because you're handling each one physically.

ExecuteUpdate is a mail-merge instruction handed to the print shop instead: "for every letter matching these criteria, change this one line." The print shop applies that instruction directly at the source, in one pass, without you ever opening a single envelope yourself.

Under the Hood

WHY ExecuteUpdate/ExecuteDelete BYPASS THE CHANGE TRACKER ENTIRELY
1. Both are terminal LINQ operators, not query-shaping ones
2. The change tracker never sees these rows
3. No SaveChangesAsync() call is involved

Common Confusion

1. "ExecuteUpdate still needs a SaveChangesAsync() call afterward"

No — it doesn't. ExecuteUpdateAsync/ExecuteDeleteAsync run and commit their SQL statement immediately when awaited. Unlike modifying a tracked entity, there's no pending, unsaved change sitting in the change tracker afterward — the database has already applied it by the time the call returns.

2. "If I already loaded some of these rows earlier in the same DbContext, ExecuteUpdate will keep them in sync"

It won't, automatically. Since the bulk operation never touches the change tracker, any entity instances you already loaded and are holding onto in the same DbContext keep their old, now-stale in-memory values — EF Core has no way to know those specific objects correspond to rows a bulk statement just changed underneath them. If you need fresh values after a bulk operation on rows you're still working with, re-query them explicitly.

Common Mistakes

Mistake 1 — Reaching for ExecuteUpdate/ExecuteDelete for ordinary, single-row CRUD

Using ExecuteUpdateAsync to change one row a user just edited in a form, when a normal tracked entity plus SaveChangesAsync() is simpler, gives you the entity's updated state back in memory for free, and integrates cleanly with any validation or side-effect logic that runs during SaveChanges. Reserve bulk operations for genuinely bulk, filter-and-change-many-rows scenarios — everyday single-entity CRUD is fine, and often clearer, with the tracked-entity pattern.

Mistake 2 — Assuming a bulk update triggers the same hooks a tracked SaveChanges would

Relying on interceptors, SaveChanges overrides, or domain-event logic that only fires during a normal tracked save, and expecting it to also fire for an ExecuteUpdateAsync call — it won't, because no SaveChangesAsync() call is involved at all. If bulk-changed rows need auditing, notification, or side effects, build that explicitly into the bulk operation's surrounding code (or into the database itself, e.g. a trigger) rather than assuming it happens automatically.

Mistake 3 — Reaching for a third-party bulk-insert library before confirming ordinary inserts are actually the bottleneck

Adding EFCore.BulkExtensions (or similar) to a project the moment more than a handful of rows need inserting, without first checking whether ordinary AddRange + SaveChangesAsync is actually too slow for the real volume involved. Ordinary batched inserts through EF Core's own change tracker are genuinely fine for moderate volumes (EF Core already batches multiple inserts into fewer round trips where the provider supports it) — reach for a dedicated bulk-insert library specifically once profiling or realistic load testing shows plain inserts are the actual bottleneck.

When Should I Use It?

SituationReach for
One row, changed by one user, as part of normal request handlingOrdinary tracked entity + SaveChangesAsync — simpler, and you get the updated entity back
Many rows, all changed the same way, matched by a filter (cleanup jobs, mass status changes, archiving)ExecuteUpdate / ExecuteDelete
Many rows deleted by a filter, no need to load them firstExecuteDelete
A large volume of brand-new rows to insert (imports, seeding, migrations)A dedicated bulk-insert library (e.g. EFCore.BulkExtensions) — EF Core has no built-in ExecuteInsert
Rows you're bulk-changing that other code in the same request still needs fresh, in-memory values forRe-query explicitly after the bulk operation — it won't update already-tracked instances for you
Rule of thumb: Bulk operations earn their keep at genuine batch scale — cleanup jobs, migrations, mass updates. For the everyday "one user, one row" path, ordinary tracked entities remain simpler and just as correct; reaching for ExecuteUpdate/ExecuteDelete there trades away change-tracking conveniences you didn't need to give up.

Mental Model

Tracked entities = load it, change it, save it — one object at a time, precisely tracked.
ExecuteUpdate/ExecuteDelete = tell the database the rule, let it apply the change to every matching row itself, in one statement.
No ExecuteInsert = a real gap; reach for a dedicated bulk-insert library at genuine volume.

Remember: bulk operations skip the change tracker entirely — nothing about them updates entities you already have loaded in memory.

Key Takeaway


Check Your Understanding

You've seen why the naive tracked-entity path gets expensive at scale, and how EF Core 7+'s bulk methods solve it. Let's confirm the reasoning stuck.

1. A job loads 50,000 tracked Order entities, sets a property on each in a loop, then calls SaveChangesAsync(). What happens at the database level?

Show answer

Correct: B

Why B is correct: SaveChangesAsync() issues one UPDATE statement per changed tracked entity — this is exactly the per-row cost the lesson opens with, and it's the reason ExecuteUpdate exists as an alternative.

Why A is incorrect: Ordinary tracked SaveChangesAsync() does not collapse many entity changes into one set-based statement — that's precisely what ExecuteUpdate does instead.

Why C is incorrect: SaveChangesAsync() on its own already sends the individual UPDATE statements — ExecuteUpdateAsync is a separate, alternative method, not a required follow-up.

Why D is incorrect: There's no hard entity-count limit that throws an exception — the cost is real but silent, which is exactly why it's easy to miss until it shows up as a performance problem at scale.

Reinforcement: The tracked-entity path's cost scales linearly with row count because each row gets its own UPDATE statement.

2. Which line of code correctly uses ExecuteUpdateAsync to set every Product's IsDiscontinued flag to true where CategoryId matches a given value?

Show answer

Correct: A

Why A is correct: This matches ExecuteUpdateAsync's real shape — filter with Where(), then describe the change with SetProperty inside the setters lambda, naming the property and its new-value expression.

Why B is incorrect: This is the tracked-entity path (and ForEach on a Task doesn't even compile as written) — it loads every row and would still need a SaveChangesAsync() call, none of which is what ExecuteUpdateAsync does.

Why C is incorrect: This isn't ExecuteUpdateAsync's real signature — it doesn't take a separate filter-predicate and an assignment lambda as two arguments; the filter belongs in a preceding Where(), and the change is described via SetProperty in a setters lambda.

Why D is incorrect: context.Update() operates on a single tracked entity, not a queryable set of rows — it isn't a bulk operation at all.

Reinforcement: ExecuteUpdateAsync's shape is: Where(filter).ExecuteUpdateAsync(setters => setters.SetProperty(property, newValue)).

3. A DbContext already has an Order entity loaded and tracked in memory. Another part of the same request then calls ExecuteUpdateAsync, which changes that same row's Status column in the database. What happens to the already-loaded, tracked Order instance?

Show answer

Correct: B

Why B is correct: Because ExecuteUpdateAsync bypasses the change tracker entirely, EF Core has no mechanism to detect that the already-tracked instance now corresponds to changed data — its in-memory Status value stays exactly as it was when loaded, until you explicitly re-query it.

Why A is incorrect: There's no automatic refresh mechanism tied to ExecuteUpdateAsync — that's exactly the gap the lesson calls out as something to watch for.

Why C is incorrect: Nothing about ExecuteUpdateAsync sets up a concurrency check against later tracked saves — it's an entirely separate execution path from SaveChangesAsync.

Why D is incorrect: The tracked instance stays exactly as tracked as it was — ExecuteUpdateAsync doesn't interact with the change tracker's state at all, in either direction.

Reinforcement: Bulk operations and the change tracker are two separate systems that don't automatically stay in sync — re-query explicitly if you need fresh values after a bulk change.

4. A team needs to insert 2 million new rows as part of a one-time data migration. Which statement about EF Core's built-in tooling for this is accurate?

Show answer

Correct: B

Why B is correct: Unlike ExecuteUpdate/ExecuteDelete, EF Core has no ExecuteInsert equivalent — there's no existing IQueryable to filter for a brand-new row. Dedicated bulk-insert libraries like EFCore.BulkExtensions are the common real-world answer at genuine high-volume scale.

Why A is incorrect: No such method exists in EF Core — this is exactly the gap the lesson explicitly calls out.

Why C is incorrect: ExecuteUpdateAsync only operates on rows that already match a filter in an existing IQueryable — it cannot create new rows that don't yet exist.

Why D is incorrect: AddRange plus SaveChangesAsync can insert large volumes and EF Core batches inserts where the provider supports it — it's not incapable, it's simply not as efficient at the very largest volumes as a dedicated bulk-insert library, which is a different claim than "incapable."

Reinforcement: The insert side of bulk operations is the one real gap in EF Core's own tooling — know to reach outside the framework for it at genuine scale.

You now know exactly when tracked entities are the right, simple default and when a set-based bulk operation is the honest answer to real scale. Next up: caching, specifically at the data-access layer.


dotnetmadeeasy.com — Learn C# and .NET, the right way.