EF Core bulk updates with ExecuteUpdate and ExecuteDelete
An EF Core bulk update that loads 50,000 entities to change one column pays per byte and per statement. ExecuteUpdate sends one statement and skips the tracker.
The nightly job that expires unpaid orders ran in eleven seconds on staging. On production it ran for four minutes, held a connection the whole time, and pushed the worker's memory high enough to page the on-call. The code did nothing exotic. It loaded the stale orders, set two properties on each, and saved. It did that for 50,000 rows.
This is the load-modify-save loop, and it is the default way anyone who learned EF Core through
SaveChanges writes a bulk update. It works, it reads well, and it pays a tax on every row that
the single SQL statement you meant to write does not.
The loop that costs 50,000 of everything
Here is the shape as it ships:
public async Task ExpireUnpaidOrdersAsync(CancellationToken cancellationToken)
{
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
var stale = await db.Orders
.Where(o => o.Status == OrderStatus.AwaitingPayment && o.PlacedAt < cutoff)
.ToListAsync(cancellationToken);
foreach (var order in stale)
{
order.Status = OrderStatus.Expired;
order.ExpiredAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
}
Count what that costs for 50,000 stale orders. An order row with a dozen columns, an address, and
a notes field is around 300 bytes on the wire, so the ToListAsync pulls roughly 15 MB into the
process to change two columns of it. Every row becomes a tracked entity, and the change tracker
keeps a snapshot of the original values so it can diff them later, so the resident cost is closer
to twice the payload plus per-entry bookkeeping: north of 30 MB for a job whose output is two column
writes per row.
Then the loop. Each SaveChangesAsync is a round trip and a commit. At 1 ms per round trip on the
same rack that is 50 seconds of waiting; at 5 ms across an availability zone it is 250 seconds,
which is the four minutes from the incident. The database also fsyncs 50,000 separate commits.
Hoisting SaveChangesAsync out of the loop is the usual first fix, and it helps less than it looks.
EF Core batches the statements, so round trips drop; on SQL Server the default batch holds 42
statements, which turns 50,000 into 1,191 round trips. But batching removes round trips, not
statements. The database still executes 50,000 separate UPDATE orders SET ... WHERE id = @id,
each with its own index lookup, now inside one transaction that holds 50,000 row locks until the
final commit. The memory bill is untouched.
Load, modify, save
SELECT 50,000 rows
about 15 MB over the wire to change two columns
50,000 tracked entities
plus a snapshot of every original value
50,000 UPDATE statements
one per SaveChanges call in the loop
... ×50,000 round trips, ×50,000 commits
✗ Memory and time grow with N
ExecuteUpdate
UPDATE orders SET ... WHERE ...
0 rows loaded, 0 entities tracked
50,000 rows updated in place
the rows never leave the database
1 round trip, 1 commit
✓ Cost stays inside the database
One statement, and the SQL it sends
EF Core 7 added ExecuteUpdate and ExecuteDelete, which translate the query, setters included,
into a single statement and run it immediately:
public async Task<int> ExpireUnpaidOrdersAsync(CancellationToken cancellationToken)
{
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
var now = DateTimeOffset.UtcNow;
return await db.Orders
.Where(o => o.Status == OrderStatus.AwaitingPayment && o.PlacedAt < cutoff)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(o => o.Status, OrderStatus.Expired)
.SetProperty(o => o.ExpiredAt, now),
cancellationToken);
}
Against PostgreSQL that becomes one UPDATE, with the enum constants inlined and the captured
locals as parameters:
UPDATE orders AS o
SET status = 3,
expired_at = @__now_0
WHERE o.status = 1 AND o.placed_at < @__cutoff_1
Zero rows cross the wire. Zero entities are tracked. One round trip, one transaction, and the return value is the row count the database reported. The database still writes 50,000 new row versions and the WAL to go with them, so the job is not free; it has stopped paying for the trip. From 1,191 round trips and 30 MB to one round trip and none is the whole performance story, and it is the same story as N+1: work that belongs in the query planner got moved into a C# loop.
The change tracker is a tool for editing the rows you loaded. A bulk update is a statement about rows you never wanted to load.
What ExecuteUpdate skips
The single statement is faster because it bypasses the machinery, and everything the machinery did for you is now not done. The EF Core documentation lists the limitations; these are the ones that bite in real code.
The change tracker does not hear about it. An Order already loaded in the same context keeps
its old Status. Nothing marks it modified, so a later SaveChanges will not undo the update, but
any decision made from that stale value is wrong. Run the bulk statement before loading, or reload
after.
Concurrency tokens are ignored. SaveChanges appends WHERE version = @original to every
update; ExecuteUpdate sends exactly the predicate you wrote. If a row must not change behind
someone's back, put the expected version in the Where and check the returned count yourself. That
is optimistic concurrency with you holding the token.
Interceptors, overrides, and domain events do not fire. Audit columns set in a
SaveChangesAsync override, soft-delete conversion, events dispatched after save: none of it
happens. ExecuteDelete also skips client-side cascade delete, so only cascades defined in the
database run.
There is no shared transaction unless you make one. Each call executes and commits on its own.
A unit of work that mixes ExecuteUpdate with SaveChanges needs BeginTransactionAsync around
both, or the first half survives the second half's failure:
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var expired = await db.Orders
.Where(o => o.Status == OrderStatus.AwaitingPayment && o.PlacedAt < cutoff)
.ExecuteUpdateAsync(
setters => setters.SetProperty(o => o.Status, OrderStatus.Expired),
cancellationToken);
db.OutboxMessages.Add(OutboxMessage.For(new OrdersExpired(cutoff, expired)));
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
Without the explicit transaction, a crash between the two calls leaves 50,000 expired orders and no outbox row to announce them. The isolation levels guide covers what that transaction does and does not promise about concurrent readers.
The shape is limited. Filtering through navigations is fine. Setters target the one entity type the query returns, and some mappings, such as hierarchies that span tables, fail at translation with an exception rather than falling back to the loop.
When the loop is still right
If expiring an order means releasing its reserved stock, notifying the customer, and raising
OrderExpired with the order's lines attached, the work per row is domain logic, and a set-based
statement cannot express it. Keep the loop, but stop paying the tax everywhere else: project only
the columns the logic needs, process in chunks of a few hundred, call SaveChangesAsync once per
chunk inside its own transaction, and clear the tracker between chunks so memory stays flat instead
of growing with N. The performance habits list covers the
projection and AsNoTracking side of that.
Chunk the big deletes
ExecuteDelete is the same idea for removals, and it introduces the opposite risk. One statement
that deletes 20 million audit rows holds its locks and its transaction for the whole run, generates
one enormous burst of write-ahead log, and leaves a replica minutes behind. Bound each statement
instead:
public async Task<int> PurgeAuditLogAsync(
DateTimeOffset cutoff,
CancellationToken cancellationToken)
{
const int chunkSize = 5_000;
var total = 0;
int deleted;
do
{
deleted = await db.AuditEntries
.Where(a => a.CreatedAt < cutoff)
.OrderBy(a => a.Id)
.Take(chunkSize)
.ExecuteDeleteAsync(cancellationToken);
total += deleted;
}
while (deleted == chunkSize);
return total;
}
Five thousand rows per statement keeps each transaction to tens of milliseconds and lets a replica
keep up; 20 million rows becomes 4,000 statements, with room for a short delay between them if
replication lag matters. The provider translates the Take into a keyed subquery or a TOP
clause, so the delete stays one statement per chunk. The OrderBy is not decoration: without a
stable order, each chunk is whichever rows the planner found first, and the loop still terminates,
but the index on Id is what keeps every chunk cheap.
Seeing the statement count
The loop version is invisible in code review because nothing about a foreach looks expensive, and
invisible in tests because the fixture has 200 rows. It surfaces when the row count does.
Katabench's EF Core puzzles run your solution against real PostgreSQL with
hidden inputs sized to expose exactly that: the same code that passes on the sample data blows its
time budget on the large input, and the grading shows the query count and the plan
next to the result. After a few rounds of watching a per-row loop collapse into one statement, you stop
reaching for the loop by reflex and start asking what single statement you actually mean.