TekinAli ⎇ main

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 + 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:

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:

  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:
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.

  1. 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.");
  1. No batching. Every call is its own round trip. Ten ExecuteUpdate calls are ten statements.
  2. 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:

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 + 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.
tekinali.db · read-only · ● connected

SQLite 3.53.3 · 8 tables, 2 views.

Click a table on the left, pick a query below or write your own. Help: \?

↑↓ select · Enter open · Esc close