newsletter

The Real Cost of Returning the Identity Value in EF Core

Download source code
7 min read

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 SaveChanges is 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 BulkInsert faster with the AutoMapOutputDirection option
  • Why BulkInsertOptimized is 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.

Copied

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:

csharp
public 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:

csharp
public 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:

csharp
public 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.

Copied

Why EF Core Is Slow When Returning Identity Values

When Product.Id is marked as ValueGeneratedOnAdd, EF Core must perform two jobs during SaveChanges:

  1. Insert the row into the database
  2. 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:

sql
MERGE [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 Product rows, 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.

Copied

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:

  • BulkInsert and BulkInsertAsync
  • BulkUpdate and BulkUpdateAsync
  • BulkDelete and BulkDeleteAsync
  • BulkMerge and BulkMergeAsync
  • BulkSynchronize and BulkSynchronizeAsync

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:

bash
dotnet add package Z.EntityFramework.Extensions.EFCore

Then add the using statement to your file:

csharp
using Z.EntityFramework.Extensions;

Now let's see how it changes the benchmark.

Copied

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:

  1. Entity Framework Extensions creates a temporary table with the same shape as the destination
  2. It uses SqlBulkCopy to stream all 10,000 rows into the temporary table in a binary format
  3. It runs a single MERGE statement that inserts the data into the destination table and returns the generated identity values
  4. It maps the returned values back to the Product.Id properties of your entities
  5. 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.

Copied

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.

Copied

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.

Copied

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:

csharp
public 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.

Copied

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:

csharp
public 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.

Copied

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 Id to populate foreign keys on child entities (use BulkInsert with IncludeGraph in this case)
  • API responses - You return the newly created Id to 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.

Copied

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 BulkInsert when you need identity values back
  • Use BulkInsert with AutoMapOutputDirection = false when you don't need identity values but still want the library's full feature set
  • Use BulkInsertOptimized when 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.

You can download source code for this newsletter for free
Download source code

Whenever you're ready, here's how I can help you:

The .NET Senior Playbook is built to:

  • Fast-track you from junior or mid-level to senior
  • Keep you growing as a senior
  • Help you beat any .NET interview

Covers everything: C#, ASP.NET Core, EF Core, system design — answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.

The .NET Senior Playbook
View the Playbook

Not sure where you stand? Take the free .NET Developer Level Test:

  • Find out your real level — Junior to Senior+
  • 15 minutes across 13 areas of C#, .NET, ASP.NET Core and System Design

No credit card required. When completed you get a personalized report: your level, your strongest areas, and where to focus next — the perfect way to benchmark yourself before diving into the Playbook.

Take the free test

Enjoyed this article? Share it with your network

Improve Your .NET and Architecture Skills

Join my community of 25,000+ developers and architects.

Each week you will get 1 practical tip with best practices and real-world examples.

Learn how to craft better software with source code available for my newsletter.

Join 25,000+ developers already reading
No spam. Unsubscribe any time.