Skip to content
Katabench
Try free
8 min read The Katabench team

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

The tax is paid in the application: memory for columns you never read and a statement for every row. One set-based statement leaves the work where the rows already live.

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.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.