When you insert thousands of rows into SQL Server with Entity Framework Core, performance drops quickly.
Most developers blame change tracking.
But there is another hidden cost that slows down every SaveChanges call: returning database-generated identity values back to your entities.
If your table has an auto-increment primary key (like most SQL Server tables), EF Core must ask the database for each generated identity after the insert.
This round-trip limits batch size, forces extra SQL logic, and is the reason SaveChanges struggles at scale.
In this post, you will learn:
- Why
SaveChangesis slow when inserting 10,000 rows into a table with an identity column - How EF Core handles generated value properties under the hood
- How Entity Framework Extensions solves this problem with
BulkInsert - How to make
BulkInsertfaster with theAutoMapOutputDirectionoption - Why
BulkInsertOptimizedis the fastest bulk insert method and how it compares with the other approaches - When you should (and shouldn't) return identity values after a bulk insert
All examples in this post were tested against a SQL Server database running in Docker, with benchmarks written in BenchmarkDotNet on .NET 10.
Let's dive in.
The Problem: Inserting 10,000 Rows with EF Core SaveChanges
I built a small benchmark project to measure how long EF Core takes to insert 10,000 Product rows into SQL Server.
Here is the Product entity:
csharppublic class Product { public int Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } public string Description { get; set; } = string.Empty; public string Sku { get; set; } = string.Empty; public string Barcode { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public string Brand { get; set; } = string.Empty; public string Manufacturer { get; set; } = string.Empty; public int StockQuantity { get; set; } public decimal Weight { get; set; } public bool IsActive { get; set; } = true; public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? UpdatedAt { get; set; } }
And the Fluent API mapping that configures the identity column:
csharppublic class ProductConfiguration : IEntityTypeConfiguration<Product> { public void Configure(EntityTypeBuilder<Product> builder) { builder.HasKey(p => p.Id); builder.Property(p => p.Id).ValueGeneratedOnAdd(); builder.Property(p => p.Name).IsRequired().HasMaxLength(250); builder.Property(p => p.Price).IsRequired().HasColumnType("decimal(18,2)"); builder.Property(p => p.Description).HasMaxLength(1000); builder.Property(p => p.Sku).IsRequired().HasMaxLength(50); builder.Property(p => p.Barcode).IsRequired().HasMaxLength(20); builder.Property(p => p.Category).IsRequired().HasMaxLength(100); builder.Property(p => p.Brand).IsRequired().HasMaxLength(100); builder.Property(p => p.Manufacturer).IsRequired().HasMaxLength(100); builder.Property(p => p.StockQuantity).IsRequired(); builder.Property(p => p.Weight).IsRequired().HasColumnType("decimal(10,3)"); builder.Property(p => p.IsActive).IsRequired().HasDefaultValue(true); builder.Property(p => p.CreatedAt).IsRequired(); builder.Property(p => p.UpdatedAt); builder.HasIndex(p => p.Sku).IsUnique(); } }
The ValueGeneratedOnAdd() call tells EF Core that the database will generate Id on insert.
On SQL Server, this maps to an IDENTITY(1,1) column.
Here is the baseline benchmark that uses the standard SaveChangesAsync approach:
csharp[Benchmark] public async Task SaveChangesAsync() { var products = GenerateProducts(10_000); _dbContext.Products.AddRange(products); await _dbContext.SaveChangesAsync(); }
I use Bogus to generate fake products:
csharppublic static List<Product> GenerateProducts(int count) { return new Faker<Product>() .RuleFor(p => p.Name, f => f.Commerce.ProductName()) .RuleFor(p => p.Description, f => f.Lorem.Paragraph()) .RuleFor(p => p.Price, f => decimal.Parse(f.Commerce.Price())) .RuleFor(p => p.Sku, f => $"SKU-{Guid.NewGuid():N}") .RuleFor(p => p.Barcode, f => f.Commerce.Ean8()) .RuleFor(p => p.Category, f => f.Commerce.Categories(1)[0]) .RuleFor(p => p.Brand, f => f.Company.CompanyName()) .RuleFor(p => p.Manufacturer, f => f.Company.CompanyName()) .RuleFor(p => p.StockQuantity, f => f.Random.Int(0, 10_000)) .RuleFor(p => p.Weight, f => Math.Round(f.Random.Decimal(0.01m, 50m), 3)) .RuleFor(p => p.IsActive, f => f.Random.Bool(0.9f)) .RuleFor(p => p.CreatedAt, f => f.Date.Past(2).ToUniversalTime()) .RuleFor(p => p.UpdatedAt, f => f.Date.Recent(30).OrNull(f, 0.3f)?.ToUniversalTime()) .Generate(count); }
On my SQL Server instance running in Docker, this benchmark takes around 7,000 ms for 10,000 rows. For production APIs with a strict p99 SLA, that is way too slow.
But here is the interesting part: the slowdown is not only change tracking.
A large part of the cost comes from EF Core's need to return the database-generated Id back to every entity.
Let's see why.
Why EF Core Is Slow When Returning Identity Values
When Product.Id is marked as ValueGeneratedOnAdd, EF Core must perform two jobs during SaveChanges:
- Insert the row into the database
- Retrieve the generated identity value and assign it back to the in-memory
Product.Id
On SQL Server, EF Core uses a MERGE statement with an OUTPUT clause to batch multiple inserts into one command and still return each new identity.
The generated SQL looks roughly like this:
sqlMERGE [products].[products] USING ( VALUES (@p0, @p1, @p2), (@p3, @p4, @p5), (@p6, @p7, @p8) ) AS i ([name], [price], [description]) ON 1 = 0 WHEN NOT MATCHED THEN INSERT ([name], [price], [description]) VALUES (i.[name], i.[price], i.[description]) OUTPUT INSERTED.[id];
This approach is clever, but it has hard limits:
- The 2,100 parameter cap - SQL Server allows a maximum of 2,100 parameters per command. With 14 mapped columns, that is roughly 100-200 rows per batch. For 10,000
Productrows, EF Core needs many round-trips. - Parameter binding cost - Every parameter has to be created, bound, and sent over the network. For 10,000 rows with a few columns, that is tens of thousands of parameters.
- Change tracker overhead - After each batch, EF Core updates the change tracker and assigns the returned identity values to each tracked entity.
- Log writes and index updates - The database writes to the transaction log and updates indexes for every batch.
You might think: "What if I disable the identity column?" That removes the OUTPUT round-trip, but now you are responsible for generating unique IDs, and you lose the simplicity of auto-increment keys.
For a deeper explanation of how EF Core handles generated values, see this article on LearnEntityFrameworkCore.
There is a better solution.
Introducing Entity Framework Extensions
Entity Framework Extensions is a library that extends EF Core with high-performance bulk operations.
It gives you extension methods on DbContext that look just like the standard EF Core API:
BulkInsertandBulkInsertAsyncBulkUpdateandBulkUpdateAsyncBulkDeleteandBulkDeleteAsyncBulkMergeandBulkMergeAsyncBulkSynchronizeandBulkSynchronizeAsync
You don't have to rewrite your entities or change your mappings.
You call one method instead of SaveChanges, and the library picks the fastest path for your database provider.
Supported database providers:
- SQL Server
- PostgreSQL
- MySQL
- MariaDB
- Oracle
- SQLite
What the library does differently on SQL Server:
Instead of issuing thousands of parameterized inserts, Entity Framework Extensions uses SqlBulkCopy to stream rows directly into the database in a binary format.
When the library needs to return identity values, it first loads the rows into a temporary table, then runs a single MERGE statement with an OUTPUT clause to copy the data into the destination table and map the identity values back to your entities.
To get started with Entity Framework Extensions, install the NuGet package:
bashdotnet add package Z.EntityFramework.Extensions.EFCore
Then add the using statement to your file:
csharpusing Z.EntityFramework.Extensions;
Now let's see how it changes the benchmark.
Bulk Insert: One Line of Code for Massive Performance Improvement
Here is the same 10,000-row insert using BulkInsertAsync:
csharp// @nuget: Z.EntityFramework.Extensions.EFCore using Z.EntityFramework.Extensions; [Benchmark] public async Task BulkInsertAsync() { var products = GenerateProducts(10_000); await _dbContext.BulkInsertAsync(products); }
That is it. One line replaces the AddRange + SaveChangesAsync pair.
By default, BulkInsertAsync still returns identity values.
After the call, every Product.Id is populated with the database-generated value, just like EF Core's SaveChanges behavior.
Here is what happens under the hood on SQL Server:
- Entity Framework Extensions creates a temporary table with the same shape as the destination
- It uses
SqlBulkCopyto stream all 10,000 rows into the temporary table in a binary format - It runs a single
MERGEstatement that inserts the data into the destination table and returns the generated identity values - It maps the returned values back to the
Product.Idproperties of your entities - It drops the temporary table
This approach removes the 2,100 parameter limit, skips EF Core's change tracker, and turns thousands of individual inserts into a single bulk stream.
On SQL Server, this benchmark typically takes around 250-500 ms for 10,000 Product rows, depending on hardware and network conditions.
That is roughly 15-28 times faster than the default SaveChangesAsync call.
But we can go further.
Bulk Insert with AutoMapOutputDirection Disabled
The AutoMapOutputDirection option controls whether database-generated values are returned back after insertion.
By default, AutoMapOutputDirection is true, which means primary keys and other database-generated columns are populated automatically:
csharp// @nuget: Z.EntityFramework.Extensions.EFCore using Z.EntityFramework.Extensions; var products = GenerateProducts(10_000); await dbContext.BulkInsertAsync(products); // After insertion, every product has its Id populated foreach (var product in products) { Console.WriteLine($"Product ID: {product.Id}"); }
When you don't need the identity values after the insert, you can turn this option off:
csharp// @nuget: Z.EntityFramework.Extensions.EFCore using Z.EntityFramework.Extensions; await dbContext.BulkInsertAsync(products, options => { options.AutoMapOutputDirection = false; });
With this option disabled, the Product.Id property stays at its default value (0 for int) after the call.
Entity Framework Extensions skips the OUTPUT step, the temporary table, and the mapping loop that assigns identity values back to each entity.
As a result, the insert is faster.
When is this a good idea? Fire-and-forget inserts, or when you just don't care about the identity values.
For example, often when you import a large dataset, you don't need to know the IDs of the inserted rows.
Comparing the First Three Approaches
Let's put all three methods side by side for 10,000 Product rows on SQL Server:
- SaveChangesAsync: 7,000 ms
- BulkInsertAsync: ~300 ms
- BulkInsertAsync with
AutoMapOutputDirection = false: ~210 ms
Expectedly, BulkInsertAsync with AutoMapOutputDirection = false is the fastest option.
However, Entity Framework Extensions has one more method that you need to know.
BulkInsertOptimized: The Fastest Method
The BulkInsertOptimized method is the recommended entry point when you don't need identity values.
In most cases, BulkInsertOptimized behaves the same as BulkInsertAsync with AutoMapOutputDirection = false.
But returns a BulkOptimizedAnalysis object with performance hints and recommendations:
csharppublic class BulkOptimizedAnalysis { /// <summary>True if the bulk insert is optimized.</summary> public bool IsOptimized { get; } /// <summary>Gets a text containing all tips to optimize the bulk insert method.</summary> public string TipsText { get; } /// <summary>Gets a list of tips to optimize the bulk insert method.</summary> public List<string> Tips { get; } }
You can inspect it after the call to check whether your operation used the fastest path:
csharp// @nuget: Z.EntityFramework.Extensions.EFCore using Z.EntityFramework.Extensions; var products = GenerateProducts(10_000); var result = await dbContext.BulkInsertOptimizedAsync(products); Console.WriteLine($"Was optimized: {result.IsOptimized}"); // {"isOptimized":true,"tipsText":"The `BulkInsertOptimized` operation is optimized.","tips":[]}
When you enable certain options, the library has to switch to a slower strategy.
For example, the InsertIfNotExists option requires a temporary table to compare existing rows, so the method loses optimization:
csharp// @nuget: Z.EntityFramework.Extensions.EFCore using Z.EntityFramework.Extensions; var products = GenerateProducts(10_000); var result = await dbContext.BulkInsertOptimizedAsync(products, options => { options.InsertIfNotExists = true; }); Console.WriteLine($"Was optimized: {result.IsOptimized}"); // Was optimized: false // Tip: "The option InsertIfNotExists = true forces the use of a less efficient strategy..."
In this case, the library returns a tip explaining why the operation is no longer fully optimized. This feedback helps you spot unexpected overhead in complex scenarios.
BulkInsertOptimized with IncludeGraph
BulkInsert and BulkInsertOptimized also support the IncludeGraph option for inserting an entire object graph in one call.
Imagine you have a ProductCart with related User and ProductCartItem entities:
csharppublic class ProductCart { public int Id { get; set; } public int Quantity { get; set; } public List<ProductCartItem> CartItems { get; set; } = []; public int UserId { get; set; } public User User { get; set; } = null!; public DateTime CreatedOn { get; set; } = DateTime.UtcNow; }
You can insert 10,000 carts with all their children in a single call:
csharp// @nuget: Z.EntityFramework.Extensions.EFCore using Z.EntityFramework.Extensions; var productCarts = GenerateProductCarts(10_000); // You can use either BulkInsert or BulkInsertOptimized await dbContext.BulkInsertAsync(productCarts, options => options.IncludeGraph = true); await dbContext.BulkInsertOptimizedAsync(productCarts, options => options.IncludeGraph = true);
When you use IncludeGraph, the library needs identity values for the parent entities to set the foreign keys on child entities.
In this case, BulkInsertOptimized falls back to the temp-table + MERGE strategy internally, but it still runs in a single logical operation.
When You Don't Need Identity Values
Identity values are not free. Returning them from a bulk insert costs you a temporary table, a MERGE round-trip, and a mapping loop over every entity. (And it costs you even more if you don't use Entity Framework Extensions library).
Skip identity values when:
- Fire-and-forget writes - You insert audit log rows, telemetry events, or metrics that are never mutated in memory afterwards (or you don't return identity values in your WebAPI responses)
- Business-key lookups - You query new rows later by serial number, email, or external reference, not by the auto-generated
Id - Data warehouse loads - Downstream analytics queries don't care about the identity column
- Bulk imports - You perform a bulk data import and you don't need identity values afterwards
Keep identity values when:
- Parent-child inserts - You insert a parent and then immediately need its
Idto populate foreign keys on child entities (useBulkInsertwithIncludeGraphin this case) - API responses - You return the newly created
Idto the caller in the HTTP response - Subsequent updates in the same unit of work - You mutate the inserted entities and save changes again
As a rule of thumb: if you don't use the entity list right after the insert, use BulkInsertOptimized.
If you keep using those entities in the same request, stick with BulkInsert and let it populate the identity values.
Summary
In this post, we looked at why EF Core SaveChanges becomes slow when inserting thousands of rows into a table with an identity column.
The root cause is not just change tracking. It is the cost of returning database-generated identity values back to your entities: batched MERGE statements, the 2,100 parameter limit, and many round-trips to the database.
Entity Framework Extensions solves this problem with a clean, one-line API:
- Use
BulkInsertwhen you need identity values back - Use
BulkInsertwithAutoMapOutputDirection = falsewhen you don't need identity values but still want the library's full feature set - Use
BulkInsertOptimizedwhen you want the fastest possible path and useful performance hints
For 10,000 rows on SQL Server, these three methods gave us roughly 33x speedups over the default SaveChangesAsync.
Entity Framework Extensions offers hundreds of options for bulk operations. If you want a deeper look at everything you can configure, check out my post on Entity Framework Extensions Options Explained.
Entity Framework Extensions is a commercial library with a free trial, which you can use to see if it's right for your project. The library requires a license for production use. See more information on licensing here.
For commercial use, the time saved in development and the improved application performance typically pay for the license within the first month of use.
Try the free trial to see how these options can transform your bulk data operations.
Many thanks to ZZZ Projects for sponsoring this blog post.
Hope you find this newsletter useful. See you next time.

