Bulk inserts, updates and deletes in EF Core: from SaveChanges batching to SqlBulkCopy
How EF Core's batched SaveChanges differs from the EF6-era approach, how ExecuteUpdate/ExecuteDelete work and where they bite, EF Core 10's conditional setters, SqlQuery for unmapped types, and when to switch to SqlBulkCopy or PostgreSQL COPY.
Load 50,000 entities to delete them one by one, or insert a million rows through the change tracker, and EF will do exactly what you asked: slowly, with a lot of memory and a lot of round trips. The usual workarounds (disable change detection, recreate the context every N rows, drop to raw SQL) come from EF6, where they made sense.
EF Core is a different engine. Some of those tricks now solve problems it mostly doesn't have, and they skip the tools it added for exactly these jobs. This article goes through those tools one by one, from batched SaveChanges to set-based updates and deletes, raw SQL into arbitrary types, and finally the database's own bulk-load APIs for when EF itself is the bottleneck.
The toolbox
- Insert:
AddRange+SaveChangesAsyncis batched in EF Core and is fine up to tens of thousands of rows. For large imports, use the provider's bulk API:SqlBulkCopyfor SQL Server, binaryCOPYthrough Npgsql for PostgreSQL. - Update/delete by condition:
ExecuteUpdateAsyncandExecuteDeleteAsync(EF Core 7+). One SQL statement, no entities loaded. - Conditional setters: since EF Core 10,
ExecuteUpdateAsyncalso accepts a plain lambda, so you can useifstatements. - Queries into arbitrary types:
Database.SqlQuery<T>with unmapped types (EF Core 8+).
Inserts: from EF6 habits to EF Core batching
The classic EF6-era recipe for fast inserts was to turn off AutoDetectChangesEnabled, call SaveChanges every N records, and dispose and recreate the context every so often. For EF6 it was sound advice. EF6's change detection got more expensive with every tracked entity, and SaveChanges sent one round trip per row.
EF Core works differently. SaveChanges batches multiple INSERT, UPDATE and DELETE commands into fewer round trips, and EF Core 7 made inserts noticeably faster again. The main remaining cost in a big loop is the change tracker holding onto every entity you've added. The modern version of "recreate the context" is just clearing it:
foreach (var chunk in incoming.Chunk(5_000))
{
db.Products.AddRange(chunk);
await db.SaveChangesAsync(ct);
db.ChangeTracker.Clear();
}
You can tune the batch size per provider (UseSqlServer(cs, o => o.MaxBatchSize(...))), but measure before you touch it. The defaults are reasonable.
Updates and deletes: ExecuteUpdate and ExecuteDelete
Older approaches to deleting many rows either loaded every entity to call Remove, dropped to raw SQL, or used extension libraries. Since EF Core 7 it's built in:
var cutoff = DateTime.UtcNow.AddDays(-90);
int deleted = await db.AuditLogs
.Where(l => l.CreatedAt < cutoff)
.ExecuteDeleteAsync(ct);
int repriced = await db.Products
.Where(p => p.CategoryId == categoryId)
.ExecuteUpdateAsync(s => s
.SetProperty(p => p.Price, p => p.Price * 1.10m)
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow), ct);
Each call becomes a single DELETE or UPDATE with your Where translated to SQL. Nothing is loaded into memory, and the work happens in the database, where it belongs.
EF Core 10: conditional setters without expression trees
Before EF Core 10, the setters argument was an expression tree. Building it conditionally (update the name only if the client sent one) meant hand-assembling Expression nodes. Few people enjoyed that. EF Core 10, the current LTS release from November 2025, accepts a regular lambda:
await db.Products
.Where(p => p.Id == id)
.ExecuteUpdateAsync(s =>
{
s.SetProperty(p => p.UpdatedAt, DateTime.UtcNow);
if (patch.Name is not null)
s.SetProperty(p => p.Name, patch.Name);
if (patch.Price is not null)
s.SetProperty(p => p.Price, patch.Price.Value);
}, ct);
For PATCH-style endpoints this removes a lot of awkward code.
The caveats, straight from the docs
These methods skip the change tracker entirely, and that has consequences. The EF Core docs list them clearly:
- The change tracker doesn't know. If you loaded an entity, then ran
ExecuteUpdateon it, the tracked instance still has the old values. A laterSaveChangescan overwrite whatExecuteUpdatedid. Don't mix the two styles on the same rows in the same context. - No implicit transaction across calls. Each
ExecuteUpdate/ExecuteDeleteruns on its own. If the second of two calls fails, the first one stays committed. Wrap them yourself:
await using var tx = await db.Database.BeginTransactionAsync(ct);
await db.OrderLines.Where(l => l.OrderId == orderId).ExecuteDeleteAsync(ct);
await db.Orders.Where(o => o.Id == orderId).ExecuteDeleteAsync(ct);
await tx.CommitAsync(ct);
If you've enabled a retrying execution strategy such as EnableRetryOnFailure, run a user-started transaction through db.Database.CreateExecutionStrategy(). Otherwise EF will refuse to start it.
- No automatic concurrency check. Use the returned row count instead:
int updated = await db.Orders
.Where(o => o.Id == orderId && o.Version == expectedVersion)
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Status, OrderStatus.Shipped)
.SetProperty(o => o.Version, o => o.Version + 1), ct);
if (updated == 0)
throw new DbUpdateConcurrencyException("Order was modified or deleted by someone else.");
- No batching. Every call is its own round trip. Ten
ExecuteUpdatecalls are ten statements. - No insert. There's no
ExecuteInsert. Inserts still go throughAdd+SaveChangesor a provider bulk API.
Raw SQL into types that aren't entities
A common workaround has been registering a keyless entity type just to run a report query. Since EF Core 8 you don't need to. Database.SqlQuery<T> maps results to any type with matching properties:
public sealed class CategoryStats
{
public int CategoryId { get; init; }
public int ProductCount { get; init; }
public decimal AveragePrice { get; init; }
}
var stats = await db.Database
.SqlQuery<CategoryStats>($"""
SELECT CategoryId, COUNT(*) AS ProductCount, AVG(Price) AS AveragePrice
FROM Products
WHERE IsActive = {onlyActive}
GROUP BY CategoryId
""")
.OrderByDescending(s => s.ProductCount)
.ToListAsync(ct);
The interpolated value becomes a real SQL parameter, not string concatenation. The result is an IQueryable, so you can compose LINQ on top of it. For statements that don't return rows, there's Database.ExecuteSqlAsync, which parameterizes the same way.
True bulk insert: use the database's own tool
For hundreds of thousands or millions of rows, even batched INSERTs are the wrong tool. Every serious database has a bulk-load path, and it's faster by a wide margin.
SQL Server, using the connection EF already has:
var table = new DataTable();
table.Columns.Add("Sku", typeof(string));
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Price", typeof(decimal));
foreach (var p in incoming)
table.Rows.Add(p.Sku, p.Name, p.Price);
await db.Database.OpenConnectionAsync(ct);
try
{
var connection = (SqlConnection)db.Database.GetDbConnection();
using var bulk = new SqlBulkCopy(connection)
{
DestinationTableName = "dbo.Products",
BatchSize = 10_000
};
bulk.ColumnMappings.Add("Sku", "Sku");
bulk.ColumnMappings.Add("Name", "Name");
bulk.ColumnMappings.Add("Price", "Price");
await bulk.WriteToServerAsync(table, ct);
}
finally
{
await db.Database.CloseConnectionAsync();
}
If there's an active EF transaction, pass it to the SqlBulkCopy constructor ((SqlTransaction)tx.GetDbTransaction()). Also note that by default bulk copy doesn't fire triggers or check constraints unless you ask for that with SqlBulkCopyOptions.
PostgreSQL, through Npgsql's binary COPY:
await db.Database.OpenConnectionAsync(ct);
var conn = (NpgsqlConnection)db.Database.GetDbConnection();
await using (var writer = await conn.BeginBinaryImportAsync(
"COPY products (sku, name, price) FROM STDIN (FORMAT BINARY)", ct))
{
foreach (var p in incoming)
{
await writer.StartRowAsync(ct);
await writer.WriteAsync(p.Sku, NpgsqlDbType.Text, ct);
await writer.WriteAsync(p.Name, NpgsqlDbType.Text, ct);
await writer.WriteAsync(p.Price, NpgsqlDbType.Numeric, ct);
}
await writer.CompleteAsync(ct); // without this, the import is rolled back
}
Both bypass EF completely. You get no generated keys back and no tracked entities. For upserts, I bulk-load into a staging table and then run one MERGE (SQL Server) or INSERT ... ON CONFLICT (PostgreSQL) with ExecuteSqlAsync.
If you'd rather not write this per provider, third-party libraries wrap these APIs behind EF-style extension methods. EFCore.BulkExtensions, linq2db.EntityFrameworkCore and Entity Framework Extensions are the usual names. Read their license terms and check which providers they support before adopting one.
Summary
- Forget the EF6 tricks. In EF Core,
AddRange+SaveChangesAsyncis batched. For long loops, addChangeTracker.Clear()between chunks. - Use
ExecuteDeleteAsync/ExecuteUpdateAsyncfor set-based changes, and remember they bypass the change tracker, don't share a transaction unless you start one, and don't check concurrency for you. - On EF Core 10, use the lambda overload of
ExecuteUpdateAsyncfor conditional updates instead of building expression trees. - Use
Database.SqlQuery<T>for reports and other read models. No fake entities needed. - For large imports, use
SqlBulkCopyor PostgreSQLCOPY, ideally into a staging table. - Measure with your own data and your own database. Benchmark numbers from someone else's setup, old or new, won't transfer to yours.
- ---
- baslik: "Bulk inserts, updates and deletes in EF Core: from SaveChanges batching to SqlBulkCopy"
- ozet: "How EF Core's batched SaveChanges differs from the EF6-era approach, how ExecuteUpdate/ExecuteDelete work and where they bite, EF Core 10's conditional setters, SqlQuery for unmapped types, and when to switch to SqlBulkCopy or PostgreSQL COPY."
- tarih: 2026-08-18
- etiketler:
- - dotnet
- - efcore
- - performance
- kategoriler:
- - dotnet
- dil: en
- ---
- Load 50,000 entities to delete them one by one, or insert a million rows through the change tracker, and EF will do exactly what you asked: slowly, with a lot of memory and a lot of round trips. The usual workarounds (disable change detection, recreate the context every N rows, drop to raw SQL) come from EF6, where they made sense.
- EF Core is a different engine. Some of those tricks now solve problems it mostly doesn't have, and they skip the tools it added for exactly these jobs. This article goes through those tools one by one, from batched `SaveChanges` to set-based updates and deletes, raw SQL into arbitrary types, and finally the database's own bulk-load APIs for when EF itself is the bottleneck.
- ## The toolbox
- - **Insert**: `AddRange` + `SaveChangesAsync` is batched in EF Core and is fine up to tens of thousands of rows. For large imports, use the provider's bulk API: `SqlBulkCopy` for SQL Server, binary `COPY` through Npgsql for PostgreSQL.
- - **Update/delete by condition**: `ExecuteUpdateAsync` and `ExecuteDeleteAsync` (EF Core 7+). One SQL statement, no entities loaded.
- - **Conditional setters**: since EF Core 10, `ExecuteUpdateAsync` also accepts a plain lambda, so you can use `if` statements.
- - **Queries into arbitrary types**: `Database.SqlQuery<T>` with unmapped types (EF Core 8+).
- ## Inserts: from EF6 habits to EF Core batching
- The classic EF6-era recipe for fast inserts was to turn off `AutoDetectChangesEnabled`, call `SaveChanges` every N records, and dispose and recreate the context every so often. For EF6 it was sound advice. EF6's change detection got more expensive with every tracked entity, and `SaveChanges` sent one round trip per row.
- EF Core works differently. `SaveChanges` batches multiple INSERT, UPDATE and DELETE commands into fewer round trips, and EF Core 7 made inserts noticeably faster again. The main remaining cost in a big loop is the change tracker holding onto every entity you've added. The modern version of "recreate the context" is just clearing it:
- ```csharp
- foreach (var chunk in incoming.Chunk(5_000))
- {
- db.Products.AddRange(chunk);
- await db.SaveChangesAsync(ct);
- db.ChangeTracker.Clear();
- }
- ```
- You can tune the batch size per provider (`UseSqlServer(cs, o => o.MaxBatchSize(...))`), but measure before you touch it. The defaults are reasonable.
- ## Updates and deletes: ExecuteUpdate and ExecuteDelete
- Older approaches to deleting many rows either loaded every entity to call `Remove`, dropped to raw SQL, or used extension libraries. Since EF Core 7 it's built in:
- ```csharp
- var cutoff = DateTime.UtcNow.AddDays(-90);
- int deleted = await db.AuditLogs
- .Where(l => l.CreatedAt < cutoff)
- .ExecuteDeleteAsync(ct);
- int repriced = await db.Products
- .Where(p => p.CategoryId == categoryId)
- .ExecuteUpdateAsync(s => s
- .SetProperty(p => p.Price, p => p.Price * 1.10m)
- .SetProperty(p => p.UpdatedAt, DateTime.UtcNow), ct);
- ```
- Each call becomes a single `DELETE` or `UPDATE` with your `Where` translated to SQL. Nothing is loaded into memory, and the work happens in the database, where it belongs.
- ### EF Core 10: conditional setters without expression trees
- Before EF Core 10, the setters argument was an expression tree. Building it conditionally (update the name only if the client sent one) meant hand-assembling `Expression` nodes. Few people enjoyed that. EF Core 10, the current LTS release from November 2025, accepts a regular lambda:
- ```csharp
- await db.Products
- .Where(p => p.Id == id)
- .ExecuteUpdateAsync(s =>
- {
- s.SetProperty(p => p.UpdatedAt, DateTime.UtcNow);
- if (patch.Name is not null)
- s.SetProperty(p => p.Name, patch.Name);
- if (patch.Price is not null)
- s.SetProperty(p => p.Price, patch.Price.Value);
- }, ct);
- ```
- For PATCH-style endpoints this removes a lot of awkward code.
- ### The caveats, straight from the docs
- These methods skip the change tracker entirely, and that has consequences. The [EF Core docs](https://learn.microsoft.com/en-us/ef/core/saving/execute-insert-update-delete) list them clearly:
- 1. **The change tracker doesn't know.** If you loaded an entity, then ran `ExecuteUpdate` on it, the tracked instance still has the old values. A later `SaveChanges` can overwrite what `ExecuteUpdate` did. Don't mix the two styles on the same rows in the same context.
- 2. **No implicit transaction across calls.** Each `ExecuteUpdate`/`ExecuteDelete` runs on its own. If the second of two calls fails, the first one stays committed. Wrap them yourself:
- ```csharp
- await using var tx = await db.Database.BeginTransactionAsync(ct);
- await db.OrderLines.Where(l => l.OrderId == orderId).ExecuteDeleteAsync(ct);
- await db.Orders.Where(o => o.Id == orderId).ExecuteDeleteAsync(ct);
- await tx.CommitAsync(ct);
- ```
- If you've enabled a retrying execution strategy such as `EnableRetryOnFailure`, run a user-started transaction through `db.Database.CreateExecutionStrategy()`. Otherwise EF will refuse to start it.
- 3. **No automatic concurrency check.** Use the returned row count instead:
- ```csharp
- int updated = await db.Orders
- .Where(o => o.Id == orderId && o.Version == expectedVersion)
- .ExecuteUpdateAsync(s => s
- .SetProperty(o => o.Status, OrderStatus.Shipped)
- .SetProperty(o => o.Version, o => o.Version + 1), ct);
- if (updated == 0)
- throw new DbUpdateConcurrencyException("Order was modified or deleted by someone else.");
- ```
- 4. **No batching.** Every call is its own round trip. Ten `ExecuteUpdate` calls are ten statements.
- 5. **No insert.** There's no `ExecuteInsert`. Inserts still go through `Add` + `SaveChanges` or a provider bulk API.
- ## Raw SQL into types that aren't entities
- A common workaround has been registering a keyless entity type just to run a report query. Since EF Core 8 you don't need to. `Database.SqlQuery<T>` maps results to any type with matching properties:
- ```csharp
- public sealed class CategoryStats
- {
- public int CategoryId { get; init; }
- public int ProductCount { get; init; }
- public decimal AveragePrice { get; init; }
- }
- var stats = await db.Database
- .SqlQuery<CategoryStats>($"""
- SELECT CategoryId, COUNT(*) AS ProductCount, AVG(Price) AS AveragePrice
- FROM Products
- WHERE IsActive = {onlyActive}
- GROUP BY CategoryId
- """)
- .OrderByDescending(s => s.ProductCount)
- .ToListAsync(ct);
- ```
- The interpolated value becomes a real SQL parameter, not string concatenation. The result is an `IQueryable`, so you can compose LINQ on top of it. For statements that don't return rows, there's `Database.ExecuteSqlAsync`, which parameterizes the same way.
- ## True bulk insert: use the database's own tool
- For hundreds of thousands or millions of rows, even batched INSERTs are the wrong tool. Every serious database has a bulk-load path, and it's faster by a wide margin.
- SQL Server, using the connection EF already has:
- ```csharp
- var table = new DataTable();
- table.Columns.Add("Sku", typeof(string));
- table.Columns.Add("Name", typeof(string));
- table.Columns.Add("Price", typeof(decimal));
- foreach (var p in incoming)
- table.Rows.Add(p.Sku, p.Name, p.Price);
- await db.Database.OpenConnectionAsync(ct);
- try
- {
- var connection = (SqlConnection)db.Database.GetDbConnection();
- using var bulk = new SqlBulkCopy(connection)
- {
- DestinationTableName = "dbo.Products",
- BatchSize = 10_000
- };
- bulk.ColumnMappings.Add("Sku", "Sku");
- bulk.ColumnMappings.Add("Name", "Name");
- bulk.ColumnMappings.Add("Price", "Price");
- await bulk.WriteToServerAsync(table, ct);
- }
- finally
- {
- await db.Database.CloseConnectionAsync();
- }
- ```
- If there's an active EF transaction, pass it to the `SqlBulkCopy` constructor (`(SqlTransaction)tx.GetDbTransaction()`). Also note that by default bulk copy doesn't fire triggers or check constraints unless you ask for that with `SqlBulkCopyOptions`.
- PostgreSQL, through Npgsql's binary COPY:
- ```csharp
- await db.Database.OpenConnectionAsync(ct);
- var conn = (NpgsqlConnection)db.Database.GetDbConnection();
- await using (var writer = await conn.BeginBinaryImportAsync(
- "COPY products (sku, name, price) FROM STDIN (FORMAT BINARY)", ct))
- {
- foreach (var p in incoming)
- {
- await writer.StartRowAsync(ct);
- await writer.WriteAsync(p.Sku, NpgsqlDbType.Text, ct);
- await writer.WriteAsync(p.Name, NpgsqlDbType.Text, ct);
- await writer.WriteAsync(p.Price, NpgsqlDbType.Numeric, ct);
- }
- await writer.CompleteAsync(ct); // without this, the import is rolled back
- }
- ```
- Both bypass EF completely. You get no generated keys back and no tracked entities. For upserts, I bulk-load into a staging table and then run one `MERGE` (SQL Server) or `INSERT ... ON CONFLICT` (PostgreSQL) with `ExecuteSqlAsync`.
- If you'd rather not write this per provider, third-party libraries wrap these APIs behind EF-style extension methods. EFCore.BulkExtensions, linq2db.EntityFrameworkCore and Entity Framework Extensions are the usual names. Read their license terms and check which providers they support before adopting one.
- ## Summary
- - Forget the EF6 tricks. In EF Core, `AddRange` + `SaveChangesAsync` is batched. For long loops, add `ChangeTracker.Clear()` between chunks.
- - Use `ExecuteDeleteAsync` / `ExecuteUpdateAsync` for set-based changes, and remember they bypass the change tracker, don't share a transaction unless you start one, and don't check concurrency for you.
- - On EF Core 10, use the lambda overload of `ExecuteUpdateAsync` for conditional updates instead of building expression trees.
- - Use `Database.SqlQuery<T>` for reports and other read models. No fake entities needed.
- - For large imports, use `SqlBulkCopy` or PostgreSQL `COPY`, ideally into a staging table.
- - Measure with your own data and your own database. Benchmark numbers from someone else's setup, old or new, won't transfer to yours.