EF Core: Bulk Operations
Reading Time: 3 minutes
Problem
Standard EF Core write workflows are entity-by-entity:
- load records into memory
- modify each entity
- call
SaveChangesAsync()
That approach is fine for small batches, but it breaks down at scale.
Typical pain points:
- slow jobs that process tens of thousands of rows
- high memory usage from tracked entities
- long transaction durations
- too many SQL statements
When teams say they need “bulk operations,” they usually mean efficient set-based updates, deletes, and inserts.
Solution
Use operation-specific strategies:
- Bulk update: use
ExecuteUpdateAsync()(EF Core 7+). - Bulk delete: use
ExecuteDeleteAsync()(EF Core 7+). - Bulk insert:
- EF Core has no native
ExecuteInsertAsync()equivalent today. - Use
AddRange+ batchedSaveChangesfor moderate volume. - For very large inserts, use provider-native bulk APIs or a vetted bulk library.
Also keep these guardrails:
- filter aggressively before bulk operations
- run inside explicit transactions when consistency requires it
- avoid mixing tracked entities with set-based operations in the same unit of work without care
Description
1) Bulk updates with ExecuteUpdateAsync
ExecuteUpdateAsync sends a direct SQL UPDATE for the matching rows without loading entities.
var affectedRows = await db.Orders
.Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
.ExecuteUpdateAsync(setters => setters
.SetProperty(o => o.Status, OrderStatus.Expired)
.SetProperty(o => o.UpdatedAtUtc, DateTime.UtcNow));
Why this is faster:
- no entity materialization
- no change tracker overhead
- single set-based SQL statement
2) Bulk deletes with ExecuteDeleteAsync
ExecuteDeleteAsync generates a direct SQL DELETE.
var deletedRows = await db.AuditLogs
.Where(x => x.CreatedAtUtc < retentionCutoff)
.ExecuteDeleteAsync();
This is ideal for retention cleanup and archival workflows.
3) Bulk inserts in EF Core
There is no built-in set-based insert API equivalent to ExecuteUpdateAsync/ExecuteDeleteAsync.
For moderate batch sizes, use batched inserts:
const int batchSize = 1000;
foreach (var batch in records.Chunk(batchSize))
{
await db.Products.AddRangeAsync(batch);
await db.SaveChangesAsync();
// Keeps memory stable during long-running import jobs.
db.ChangeTracker.Clear();
}
For very large imports (hundreds of thousands to millions of rows), prefer:
- SQL Server
SqlBulkCopy - PostgreSQL
COPY - or a trusted EF-oriented bulk package after validation
4) Important behavioral notes
Set-based APIs (ExecuteUpdateAsync, ExecuteDeleteAsync) do not behave like tracked updates:
- they bypass EF change tracking
- they do not trigger per-entity domain logic in memory
- they can bypass concurrency workflows that depend on tracked originals
If you rely on audit fields, interceptors, domain events, or optimistic concurrency stamps, verify behavior explicitly in integration tests.
5) Example: transaction-safe maintenance job
await using var tx = await db.Database.BeginTransactionAsync();
var updated = await db.Orders
.Where(o => o.Status == OrderStatus.Pending && o.CreatedAt < cutoff)
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Status, OrderStatus.Expired)
.SetProperty(o => o.UpdatedAtUtc, DateTime.UtcNow));
var deleted = await db.OrderDrafts
.Where(d => d.CreatedAtUtc < retentionCutoff)
.ExecuteDeleteAsync();
await tx.CommitAsync();
This keeps multi-step maintenance work consistent and operationally simple.
Summary
EF Core supports true set-based bulk operations for updates and deletes via:
ExecuteUpdateAsyncExecuteDeleteAsync
For inserts, there is no native set-based bulk insert API yet, so use:
AddRange+ batched saves for medium workloads- provider-native bulk tools or a mature library for large ingestion pipelines
Choosing the correct path per operation gives predictable performance and avoids unnecessary tracker overhead.
References
- Microsoft Docs: ExecuteUpdate and ExecuteDelete
- Microsoft Docs: Saving Data in EF Core
- Microsoft Docs: Change Tracking
- Microsoft Docs: Performance - Efficient Updating