EfCore.BulkOperations 2.0.0

dotnet add package EfCore.BulkOperations --version 2.0.0
                    
NuGet\Install-Package EfCore.BulkOperations -Version 2.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="EfCore.BulkOperations" Version="2.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="EfCore.BulkOperations" Version="2.0.0" />
                    
Directory.Packages.props
<PackageReference Include="EfCore.BulkOperations" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add EfCore.BulkOperations --version 2.0.0
                    
#r "nuget: EfCore.BulkOperations, 2.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package EfCore.BulkOperations@2.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=EfCore.BulkOperations&version=2.0.0
                    
Install as a Cake Addin
#tool nuget:?package=EfCore.BulkOperations&version=2.0.0
                    
Install as a Cake Tool

EfCore.BulkOperations

BulkInsertAsync, BulkUpdateAsync, BulkDeleteAsync and BulkMergeAsync on your DbContext. Each one builds a single parameterised statement from EF Core's own model metadata and sends it straight to the connection, instead of going through the change tracker.

MySQL and MariaDB only. Identifiers are backtick-quoted in every statement, update and delete use MySQL's multi-table INNER JOIN form, and BulkMergeAsync is built on ON DUPLICATE KEY UPDATE. None of the four is portable to SQL Server or PostgreSQL.

Source and full benchmark · NuGet


Example

Every call takes a list and returns the number of rows affected. The examples below share one list:

var products = new List<Product> { new("Product1", 100m) };

Bulk Insert

var rowsAffected = await dbContext.BulkInsertAsync(products);
await dbContext.BulkInsertAsync(products, option =>
{
    option.BatchSize = 1000;
    option.CommandTimeout = 120;
    option.IgnoreOnInsert = x => new { x.CreatedAt };
});

Bulk Update

await dbContext.BulkUpdateAsync(products);
// Leave a column as the database has it
await dbContext.BulkUpdateAsync(products, option => option.IgnoreOnUpdate = x => new { x.CreatedAt });

// Match rows on something other than the unique index found in the model
await dbContext.BulkUpdateAsync(products, option => option.UniqueKeys = x => new { x.Id });

Bulk Delete

await dbContext.BulkDeleteAsync(products);

await dbContext.BulkDeleteAsync(products, option => option.UniqueKeys = x => new { x.Id });

Bulk Merge

Insert rows that are new and update the ones that are not, in one statement.

await dbContext.BulkMergeAsync(products);

The number returned follows MySQL's rule for ON DUPLICATE KEY UPDATE: 1 for a row that was inserted and 2 for one that was updated, so it is not the number of rows in the list. A row that matched but already had these values counts 1 with MySqlConnector's default UseAffectedRows=false (the connection reports matched rows), and 0 if the connection string sets UseAffectedRows=true.

await dbContext.BulkMergeAsync(products, option =>
{
    option.IgnoreOnInsert = x => new { x.CreatedAt };
    option.IgnoreOnUpdate = x => new { x.CreatedAt };
});

Options

Option Default What it does
BatchSize 500 Rows per statement. See Batch size for how the number was chosen.
CommandTimeout the provider's Seconds before the command is abandoned.
UniqueKeys the unique index in the model, or else its primary key Which columns update and delete match rows on. Only x => x.Prop and x => new { x.A, x.B } are accepted; anything else throws.
IgnoreOnInsert none Columns to leave out of an insert, so the database's own default applies.
IgnoreOnUpdate none Columns to leave untouched by an update.
SortByKeys true Order rows by their keys before sending. Worth several times the speed on a large write — see Why the rows are sorted. Turn it off only if the rows must arrive in the order given.

Sharing one transaction

Each call runs in its own transaction unless you hand it one. A transaction you pass in stays yours: the library will not commit it, roll it back, or close the connection. The same applies to a transaction the context already has open through Database.BeginTransactionAsync() - it is picked up automatically - and to a connection you opened yourself, which is left open.

Entities with a shadow property (a foreign key EF Core added for a navigation, a TPH discriminator, or one declared with Property<T>("Name")) are rejected with NotSupportedException: there is no CLR property to read the value from.

var transaction = await dbContext.BeginTransactionAsync();

try
{
    await dbContext.Products.AddAsync(product);
    await dbContext.SaveChangesAsync();

    await dbContext.BulkInsertAsync(orders, null, transaction);
    await dbContext.BulkInsertAsync(logs, null, transaction);

    await dbContext.CommitAsync();
}
catch
{
    await dbContext.RollbackAsync();
    throw;
}

Benchmark

Against plain EF Core on the same data, schema and MySQL instance, at MySQL's default settings. Ratio is this library's mean divided by EF Core's, so 0.26 means it took 26% of the time.

Operation 1,000 10,000 100,000 1,000,000
Delete 0.18 0.16 0.18 0.33
Update 0.26 0.34 0.29 0.26
Insert 0.47 0.59 0.39 0.34
Merge 0.70 0.89 0.86 0.70

Allocation is lower everywhere too, most of all on delete, which sends only the key columns: 86 MB against EF Core's 576 MB at a hundred thousand rows.

Merge is the narrowest margin because EF Core's baseline for it is already the fastest thing EF Core can do - look the rows up in chunks, then update or add each one.

Method, hardware, error bars and the batch-size sweep

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 was computed.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.0.0 143 8/23/2026
1.6.0 1,165 4/21/2025
1.5.1 9,986 3/16/2025
1.5.0 467 12/6/2024
1.4.1 252 8/5/2024
1.3.0 267 5/20/2024
1.2.0 269 5/10/2024
1.1.1 256 5/9/2024
1.1.0 256 5/7/2024
1.0.1 256 5/7/2024
1.0.0 223 5/6/2024

performance improve