Unveiling the Performance Bottleneck: The Real Cost of Returning Identity Values in Entity Framework Core

Main Facts
When enterprise applications scale up to handle thousands or millions of concurrent database writes, performance tuning becomes an exercise in identifying microscopic bottlenecks. For developers utilizing Entity Framework Core (EF Core) alongside Microsoft SQL Server, inserting bulk datasets has long been a notorious pain point. While conventional wisdom within the .NET community frequently places the blame squarely on EF Core’s internal change-tracking mechanism, recent profiling reveals a far more insidious culprit hiding in plain sight: the overhead associated with returning database-generated identity values.
When a table utilizes an auto-incrementing primary key—a foundational design pattern for the vast majority of SQL Server relational models—EF Core is forced to query the database to retrieve each newly minted identity value immediately following an insert operation. This seemingly minor operational requirement triggers a cascading series of performance constraints. It restricts batch sizes, necessitates complex underlying SQL syntax, and ultimately throttles the throughput of the SaveChanges pipeline when scaling up.
Recent benchmarks conducted on a Docker-hosted SQL Server instance utilizing .NET 10 and measured via BenchmarkDotNet have brought precise metrics to this phenomenon. Inserting just 10,000 highly configured product rows through the standard SaveChangesAsync pipeline takes approximately 7,000 milliseconds—a latency profile that directly violates strict p99 Service Level Agreements (SLAs) required by modern, high-throughput enterprise APIs.
Chronology and Context: The Evolution of EF Core Batching
To understand how the developer ecosystem arrived at this performance inflection point, one must look back at the architectural evolution of Object-Relational Mappers (ORMs) in the .NET space.
Early Limitations
In earlier iterations of Entity Framework, inserting large collections of entities was notoriously inefficient. Every single entity required an individual round-trip to the database, resulting in the dreaded N+1 insert problem. If a developer attempted to save 10,000 rows, EF would execute 10,000 separate INSERT statements, destroying network throughput and overwhelming database connection pools.
The Introduction of Statement Grouping and Bulk Updates
With the release of EF Core, Microsoft introduced significant architectural overhauls, optimizing how SQL commands are batched and dispatched to the server. Developers were given robust tools like AddRange paired with SaveChanges. However, these optimizations still operated under the strict constraint of entity state management. Because EF Core tracks instances to update navigation properties, foreign keys, and primary keys within the local unit of work, it needs to know the final state of the database record.
The Modern Benchmark Era (.NET 10)
As .NET 10 pushes the boundaries of raw runtime performance, developers are moving beyond simple micro-benchmarks to profile complex entity structures. By configuring a standard Product entity with comprehensive metadata—including string properties, precision-configured decimals, foreign keys, and unique indexes—engineers are discovering that even with optimized .NET runtimes, data-layer architecture remains bounded by fundamental database round-trip rules.
Supporting Data: Benchmarking the 10,000-Row Barrier
To quantify the exact performance impact, developers have turned to rigorous benchmarking frameworks like BenchmarkDotNet. The baseline scenario involves setting up a realistic enterprise entity representing a product catalog item.

The Product Entity Structure
Consider a typical e-commerce Product class configured with standard attributes and a database-generated identity:
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;
Fluent API Configuration
To ensure EF Core understands how the primary key is generated, developers use the Fluent API configuration:
public class ProductConfiguration : IEntityTypeConfiguration<Product>
public void Configure(EntityTypeBuilder<Product> builder)
builder.HasKey(p => p.Id);
// Instructs EF Core that the database generates the Id on insert
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 Benchmark Implementation
Using popular data-generation libraries like Bogus, engineers can simulate realistic enterprise workloads:
[Benchmark]
public async Task SaveChangesAsync()
var products = GenerateProducts(10_000);
_dbContext.Products.AddRange(products);
await _dbContext.SaveChangesAsync();
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);
When executed on a containerized SQL Server instance, processing these 10,000 rows standardizes around a 7-second execution window. While acceptable for background workers or scheduled imports, this latency proves catastrophic for user-facing endpoints that must respond within hundreds of milliseconds.
Official Responses and Technical Analysis: Why EF Core Struggles
To diagnose why performance degrades so drastically, database architects and EF Core maintainers look closely at the underlying SQL generated by the framework.
When a property is decorated with ValueGeneratedOnAdd(), EF Core faces a dual responsibility during a SaveChanges execution:
- It must construct a batch command to insert the rows efficiently.
- It must capture and assign the newly generated database identities back to the corresponding properties of the managed entity instances in memory.
The Mechanics of the MERGE Statement
To accomplish this on SQL Server, EF Core leverages an advanced SQL construct: a MERGE statement combined with an OUTPUT clause. This allows the framework to bundle multiple inserts into a single command while simultaneously requesting the newly minted keys.
The generated SQL resembles the following structure:

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];
Hard Limits of the OUTPUT Approach
While clever, this mechanism is bound by severe hardware and engine limitations:
- Memory and Log Pressure: Large
MERGEstatements with massiveVALUESclauses consume significant query compilation and execution memory on the SQL Server engine. - Network Payload Round-Trips: Streaming thousands of generated identity values back from the server to the client application saturates network buffers.
- Batch Size Restrictions: SQL Server enforces hard limits on parameter counts and statement lengths, preventing developers from sending unlimited rows in a single batch without encountering explicit exceptions.
If a developer decides to bypass identity generation entirely by handling IDs on the client side (e.g., using GUIDs or pre-allocated sequence ranges), the OUTPUT round-trip vanishes. However, this shifts the burden of uniqueness and index fragmentation management back onto the application layer, sacrificing the native simplicity of auto-incrementing integer keys.
Implications for Enterprise Architecture
The discovery that identity value return paths are a primary bottleneck forces enterprise architects to rethink how data ingestion pipelines are designed within modern .NET ecosystems.
1. Architectural Trade-offs
Developers must consciously weigh the convenience of auto-incrementing surrogate keys against bulk-loading performance requirements. For systems that experience high-frequency, high-volume data ingestion—such as IoT telemetry, financial ledgers, or massive e-commerce catalog syncs—relying on standard EF Core SaveChanges loops with identity columns is unsustainable.
2. Adoption of Alternative Patterns
To circumvent these performance penalties, enterprise teams are increasingly turning to specialized patterns:
- Bulk Extensions: Utilizing third-party libraries like
EFCore.BulkExtensionsthat bypass the standard change tracker and leverage native database bulk-copy operations (SqlBulkCopyin SQL Server). - Client-Generated Keys: Shifting toward GUIDv7 or HiLo generation algorithms, which allow entities to know their primary keys prior to database insertion, thereby completely eliminating the need for database round-trips to fetch identity values.
- Staging Tables: Writing incoming streams to unindexed staging tables via high-performance bulk loaders, followed by set-based stored procedures to merge data into production tables asynchronously.
3. Conclusion
Entity Framework Core remains a premier ORM for developer productivity and maintainability, but it is not a silver bullet for high-volume data processing. By understanding the hidden cost of returning database-generated identity values, software engineers can make informed architectural decisions, optimizing their data layers to meet the rigorous performance demands of modern enterprise applications.
