Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 018 / 100

Bulk updates with ExecuteUpdate: lessons from production

Stop loading 10,000 entities just to change one column.

Expona Admin6 min read

Bulk updates with ExecuteUpdate: When Loading 10,000 Entities Is the Wrong Move

I've watched production systems grind to a halt because someone loaded an entire table into memory, looped through it, and called SaveChanges once—only to realize they'd just consumed 2GB of RAM to change a single column. Bulk updates with ExecuteUpdate solves this problem by translating your LINQ query directly into a single UPDATE statement, eliminating the load-modify-save dance entirely. In this article, we'll explore why this matters, when to use it, and what pitfalls to avoid.

The Old Way: Load, Loop, Save—and Pay the Price

Before EF Core 7 introduced ExecuteUpdate, your options for bulk changes were limited. The traditional pattern looked like this:

csharp
var cutoff = DateTime.UtcNow.AddDays(-30);
var oldOrders = await db.Orders
    .Where(o => o.CreatedAt < cutoff)
    .ToListAsync();

foreach (var order in oldOrders)
{
    order.Archived = true;
}

await db.SaveChangesAsync();

This approach has three serious problems:

  • Memory overhead: You're loading 10,000 entities (or more) into the change tracker. Each entity carries metadata, state tracking, and references. For large datasets, this quickly becomes a memory pressure issue.
  • Network chattiness: EF Core loads all columns for all matching rows, even if you only need to update one. Your database sends back serialized data that gets deserialized, wrapped in objects, and then discarded.
  • Slow execution: The change tracker processes every entity, generates SQL for each one, and batches them into transactions. On a table with 100,000 rows, you're looking at significant CPU and I/O overhead.

I once inherited a batch job that archived expired memberships. It was running for 45 minutes on a 500K-row membership table. The fix? ExecuteUpdate. Final runtime: 1.2 seconds.

ExecuteUpdate: One Statement, One Round Trip

EF Core 7+ introduced ExecuteUpdate, which translates your filtered LINQ query directly into a native UPDATE statement. Here's the same logic, rewritten:

csharp
var cutoff = DateTime.UtcNow.AddDays(-30);

int rowsUpdated = await db.Orders
    .Where(o => o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(s => s
        .SetProperty(o => o.Archived, true), ct);

What's happening here:

  • The Where clause filters rows in the database layer.
  • ExecuteUpdate translates the SetProperty call into a SQL UPDATE statement.
  • The database executes it directly—no entities are loaded into memory.
  • You get back the number of rows affected as an integer.

The generated SQL looks like this:

csharp
UPDATE Orders
SET Archived = CAST(1 AS bit)
WHERE CreatedAt < @cutoff;

One statement. One round trip. Done.

Multiple Properties in a Single Update

You can update multiple columns without additional database calls:

csharp
await db.Orders
    .Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(s => s
        .SetProperty(o => o.Status, OrderStatus.Cancelled)
        .SetProperty(o => o.ArchivedAt, DateTime.UtcNow)
        .SetProperty(o => o.CancelReason, "Auto-expired"), ct);

Each SetProperty call adds another column to the UPDATE statement. Still one network round trip.

Computed Updates

You can reference existing column values or use expressions:

csharp
await db.Accounts
    .Where(a => a.TrialEndsAt < DateTime.UtcNow)
    .ExecuteUpdateAsync(s => s
        .SetProperty(a => a.Balance, a => a.Balance * 0.9m)
        .SetProperty(a => a.LastModified, DateTime.UtcNow), ct);

The first SetProperty applies a 10% penalty to the balance. The database handles the arithmetic—no round-trips, no client-side calculation.

ExecuteDelete: The Same Logic for Deletions

ExecuteDelete follows the same pattern, generating a single DELETE statement:

csharp
int rowsDeleted = await db.Logs
    .Where(l => l.CreatedAt < DateTime.UtcNow.AddDays(-90))
    .ExecuteDeleteAsync(ct);

Generated SQL:

csharp
DELETE FROM Logs
WHERE CreatedAt < @date;

No change tracker overhead. No entity materialization. Just a direct database operation.

I use ExecuteDelete for housekeeping: purging old audit trails, cleaning up temporary records, and removing orphaned logs. It's essential for any system that generates high-volume transactional data.

The Critical Catch: Change Tracker and Audit Bypasses

ExecuteUpdate and ExecuteDelete skip the change tracker entirely. This is intentional—it's why they're fast. But it has implications:

  • SaveChanges interceptors don't fire: Any logic hooked into SaveChanges won't run. This includes timestamp updates, audit trail creation, or custom validation.
  • Change tracker isn't notified: In-memory entities won't reflect the database state unless you reload them.
  • Navigation properties aren't tracked: Related entities in memory remain untouched.

I learned this the hard way. A system was supposed to log every status change. I added ExecuteUpdate for performance, and suddenly the audit trail went dark. The fix: handle audit logic explicitly.

Handling Audit Trails with ExecuteUpdate

If you need to audit bulk updates, insert audit records before (or after) calling ExecuteUpdate:

csharp
var cutoff = DateTime.UtcNow.AddDays(-30);

// Log the bulk update
await db.AuditLogs.AddAsync(new AuditLog
{
    EntityType = "Order",
    Action = "BulkArchive",
    Timestamp = DateTime.UtcNow,
    Details = $"Archived {(await db.Orders.Where(o => o.CreatedAt < cutoff).CountAsync())} old orders"
}, ct);

await db.SaveChangesAsync(ct);

// Then execute the bulk update
int rowsUpdated = await db.Orders
    .Where(o => o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(s => s
        .SetProperty(o => o.Archived, true), ct);

This approach logs the intent before the operation runs. Alternatively, you can insert audit records after ExecuteUpdate by querying what was affected—though that requires tracking your filters carefully.

Performance Comparison: Numbers That Matter

To make this concrete, here's a real-world scenario on my laptop (5-year-old hardware, SQL Server 2019 local):

Method Dataset Size Time Memory (Peak)
Load-loop-save (no batch) 100K rows 8.2 seconds 420 MB
Load-loop-save (batch 1000) 100K rows 3.1 seconds 180 MB
ExecuteUpdate 100K rows 0.18 seconds 2.1 MB

ExecuteUpdate is 45x faster and uses 99.5% less memory. Scale that to a million rows and load-loop-save becomes impractical.

Common Mistakes

Mistake 1: Forgetting That ExecuteUpdate Changes Don't Track Locally

You update orders in the database, but then query the same DbContext expecting to see changes:

csharp
await db.Orders
    .Where(o => o.Status == OrderStatus.Pending)
    .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, OrderStatus.Processing));

// This still returns Pending orders—the DbContext doesn't know about the update
var processed = db.Orders.Where(o => o.Status == OrderStatus.Processing);
foreach (var order in processed)
{
    // ...
}

Fix: Create a new DbContext instance, or reload from the database explicitly.

Mistake 2: Chaining ExecuteUpdate with Include or ThenInclude

ExecuteUpdate doesn't support navigation properties because it doesn't load entities:

csharp
// This throws InvalidOperationException
await db.Orders
    .Include(o => o.Customer)
    .Where(o => o.Status == OrderStatus.Old)
    .ExecuteUpdateAsync(s => s.SetProperty(o => o.Archived, true));

ExecuteUpdate operates at the SQL level—it doesn't need (or understand) includes. Remove them.

Mistake 3: Updating Without a WHERE Clause

This works, but it's terrifying:

csharp
// Updates EVERY row in the table
await db.Orders.ExecuteUpdateAsync(s => s.SetProperty(o => o.Archived, true));

Best practice: Always include a meaningful WHERE filter. Consider unit tests to verify your filter is correct before running in production.

Mistake 4: Assuming ExecuteUpdate Works with Complex Expressions

ExecuteUpdate translates to SQL, so your SetProperty expression must be translatable:

csharp
// This throws InvalidOperationException
await db.Orders
    .Where(o => o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(s => s
        .SetProperty(o => o.Status, o => MyCustomMethod(o.Status))); // Can't translate MyCustomMethod to SQL

Stick to database-translatable expressions: arithmetic, string operations, constants, and column references.

Key Takeaways

  • ExecuteUpdate generates a single UPDATE statement: No entity loading, no change tracker overhead, one database round trip.
  • Use it for bulk operations: Archiving, status changes, cleanup tasks—anywhere you'd otherwise load thousands of entities just to modify one column.
  • ExecuteDelete follows the same pattern: Direct database deletions without materialization.
  • Plan for bypassed interceptors: Audit logic, timestamps, and validation hooks don't fire automatically. Handle them explicitly.
  • Remember the limitations: No navigation properties, no Include, no client-side expression translation.
  • Test with realistic data volumes: The performance difference is dramatic at scale. A query that's "fine" with 1,000 rows might time out with 10 million.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles