The Real Cost of Returning the Identity Value in EF Core
Why EF Core asking the database for the new Id after every insert costs round trips, and how HiLo, sequences, and Guids cut that cost down.
When you save a new row in EF Core, something quiet happens that most people never notice. EF Core does not just send your row to the database. It also asks the database, "What Id did you give my new row?" That little question has a price. This post is about that price, why it exists, and how to make it smaller.
A simple everyday story
Think about getting a token number at a busy bank or a hospital in your city.
You walk up to the counter. The clerk takes your form, writes you into the register, and then hands you a small printed slip with your token number on it. You did not pick the number. The clerk picked it for you. And here is the key part: you have to wait at the counter until the clerk prints and gives you that slip. You cannot walk away the moment you hand over the form. You must stand there for the slip.
That waiting is the cost.
In EF Core, you are the application. The clerk is the database. The token number is the identity value (the new Id). When you insert a row with a database-generated key, your app hands over the row and then waits for the database to hand back the new Id. That round trip, that little wait, is the real cost we are going to study.
If you only get one token, the wait is nothing. But imagine you need 1000 tokens, one person at a time, each waiting for their own slip. Suddenly all that waiting adds up.
What "identity value" even means
In most .NET apps you write an entity like this.
public class Blog
{
public int Id { get; set; } // primary key
public string Title { get; set; } = "";
}You never set Id yourself. You just add the blog and save.
var blog = new Blog { Title = "My First Post" };
db.Blogs.Add(blog);
await db.SaveChangesAsync();
// After save, blog.Id is filled in for you, e.g. 42
Console.WriteLine(blog.Id);By convention, a non-composite primary key of type short, int, long, or Guid gets value generation set up for you. On SQL Server, that numeric key becomes an IDENTITY column. The database picks the number. It starts at 1 (the seed) and goes up by 1 each time (the increment).
So who fills blog.Id with 42? EF Core does. But to do that, EF Core must first ask the database what number it chose.
Where the cost actually comes from
The cost is the round trip. A round trip is one full conversation with the database: your app sends a request, the database replies. Each round trip has a fixed overhead. Network time. Connection time. The database parsing and planning the command. Even on a fast local machine, it is not free.
When the key is generated by the database, EF Core cannot know the Id until the database tells it. So EF Core has to wait for the reply before it can do anything that depends on that Id.
On SQL Server, EF Core gets the value back using the SQL OUTPUT clause attached to the insert. Older patterns used SELECT SCOPE_IDENTITY() after the insert. Either way, the idea is the same: the insert is paired with a read-back of the generated value.
The read-back path
Steps
Insert sent
App sends the new row
DB generates Id
IDENTITY picks a number
Id read back
OUTPUT returns the value
Entity updated
EF Core sets entity.Id
One row is cheap. Many rows can be costly.
Here is the part people get wrong. They hear "extra round trip" and panic. For a single insert, the cost is tiny. You will never see it.
The trouble starts in two common situations.
Situation 1: parent and child together
Say you add a Blog and three Posts for that blog. The posts need the blog's Id as a foreign key. But the blog's Id does not exist until the blog is inserted and the database returns it.
var blog = new Blog { Title = "Travel Diary" };
blog.Posts.Add(new Post { Text = "Day 1" });
blog.Posts.Add(new Post { Text = "Day 2" });
blog.Posts.Add(new Post { Text = "Day 3" });
db.Blogs.Add(blog);
await db.SaveChangesAsync();If the blog key is database-generated, EF Core cannot insert the posts in the same batch as the blog. It must first insert the blog, wait for the returned Id, and only then insert the posts using that Id. The dependency forces an ordering, and ordering can force extra round trips.
Situation 2: many separate saves
If you loop and call SaveChanges per row, every single save is its own round trip with its own read-back. One hundred rows can mean one hundred trips to the counter, each waiting for its own token slip.
// Slow pattern: a round trip per row
foreach (var name in names)
{
db.Customers.Add(new Customer { Name = name });
await db.SaveChangesAsync(); // waits for the Id every time
}Batch the work instead. Add everything, then save once.
// Better: add all, save once
foreach (var name in names)
{
db.Customers.Add(new Customer { Name = name });
}
await db.SaveChangesAsync(); // EF Core batches the insertsThe cost compared in a table
Here is a rough comparison of the patterns. Numbers are not exact; they show the shape of the cost.
| Pattern | Key source | Round trips for parent + 3 children | Notes |
|---|---|---|---|
| Save per row | DB IDENTITY | Many (one per save) | Worst case, easy mistake |
| One save, DB key | DB IDENTITY | Parent first, then children | Read-back forces ordering |
| One save, client key | HiLo or Guid | Often a single batch | No read-back needed |
And here is how the key strategies stack up.
| Strategy | Who picks the Id | Extra read-back trip? | Good when |
|---|---|---|---|
| IDENTITY column | Database | Yes | Simple apps, few inserts |
| HiLo | Client (from a cached range) | No | Many inserts, parent/child trees |
| Database sequence | Database, but pre-fetchable | Sometimes | You want ordered numbers |
| Guid | Client | No | Distributed systems, merges |
The fix: pick the key on the client side
Go back to the bank story. What if you could pick your own token number before you even reached the counter, and the bank trusted it? You would hand over your form with your number already on it. No waiting for a slip. You could even fill out forms for your whole family at once, all with numbers, and drop them together.
That is exactly what client-side key generation does. If your app already knows the Id before the insert, EF Core does not need a read-back. And because the parent's key is known up front, the children's foreign keys are known too, so everything can go in one batch.
Client-side keys remove the wait
Steps
App makes Id
HiLo or Guid
Parent + children ready
All FKs known
Single batch insert
One round trip
No read-back
Nothing to wait for
Option A: HiLo
HiLo means "high-low". EF Core sets up a database sequence and grabs a block of values at once (10 by default), then caches them in memory. When you insert rows, EF Core hands out the next cached number with no trip to the server. When the block runs out, it grabs the next block.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Blog>()
.Property(b => b.Id)
.UseHiLo("blog_hilo"); // sequence name
}Because the key is known before the insert, EF Core can batch a parent and its children into a single round trip. In older EF Core a parent-plus-children save could be four round trips. With cached HiLo keys it can drop to one. That is the win.
Option B: a database sequence
A sequence is a database object that just counts up. You can attach it to a key and even pre-fetch values, similar in spirit to HiLo, while keeping the numbers ordered and managed by the database.
modelBuilder.HasSequence<int>("order_numbers")
.StartsAt(1000)
.IncrementsBy(1);
modelBuilder.Entity<Order>()
.Property(o => o.Id)
.HasDefaultValueSql("NEXT VALUE FOR order_numbers");Option C: a Guid key
A Guid (a big random unique value) can be made entirely on the client. No database, no read-back, ever. This is great for distributed systems where many machines create rows at the same time.
public class Document
{
public Guid Id { get; set; } = Guid.NewGuid(); // made on the client
public string Name { get; set; } = "";
}The trade-off: random Guids can hurt index performance because new rows scatter across the index instead of landing at the end. If that matters, look at sequential Guid generators, which keep the values mostly in order.
So should you stop using IDENTITY?
No. IDENTITY is a fine default. It is simple, it gives clean small numbers, and for most apps the read-back cost is invisible. The clerk handing you one token slip is not a problem.
Reach for HiLo, sequences, or Guids when you have a real, measured need:
- You insert many rows often.
- You insert parent and child trees and want them in one batch.
- You build IDs across many machines at once.
The state diagram below shows how EF Core decides whether a read-back is needed.
A quick way to see it yourself
Turn on EF Core logging and watch the SQL it sends. With an IDENTITY key you will see the INSERT paired with an OUTPUT clause that brings back the generated Id. Switch the same entity to HiLo or a Guid, run the same code, and you will see the read-back disappear and the inserts group into tighter batches. Seeing is believing, and it teaches more than any blog post.
Always measure before you change. The "cost" is real, but it is only worth fixing when your numbers say so. Premature optimization can make code harder to read for no real gain.
Quick recap
- When a key is database-generated (IDENTITY), EF Core must ask the database for the new
Idafter the insert. That is an extra round trip, like waiting at a counter for your token slip. - For one row, the cost is tiny. It grows when you insert many rows or insert a parent with children, because the read-back can force extra trips and block batching.
- On SQL Server, EF Core uses the
OUTPUTclause (older code usedSCOPE_IDENTITY) to bring the value back. - Client-side keys remove the wait. With HiLo, sequences, or Guid keys, your app knows the
Idbefore the insert, so EF Core can batch many inserts into a single round trip. - IDENTITY is still a great default. Only switch when you insert a lot, build parent/child trees, or generate IDs across many machines, and only after you measure.
References and further reading
- Generated Values - EF Core (Microsoft Learn)
- Microsoft SQL Server Value Generation - EF Core (Microsoft Learn)
- Saving Data - EF Core (Microsoft Learn)
- Efficient Querying - EF Core (Microsoft Learn)
- Announcing EF Core 7 Preview 6: Performance Optimizations (.NET Blog)
- How to use HiLo with Entity Framework Core (Dave Callan)
Related Posts
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
EF Core Bulk Insert: Boost Performance with Entity Framework Extensions
Learn how EF Core bulk insert with Entity Framework Extensions saves data faster, using simple examples, diagrams, and clear performance comparisons.
Fast SQL Bulk Inserts With C# and EF Core: A Beginner Guide
Learn fast SQL bulk inserts in C# and EF Core using AddRange, batching, SqlBulkCopy, and bulk libraries, with simple diagrams and clear examples.
How to Use the New Bulk Update Feature in EF Core 7
Learn EF Core 7 bulk updates with ExecuteUpdate and ExecuteDelete. Update or delete many rows in one fast SQL trip, no entity loading needed.
What You Need to Know About EF Core Bulk Updates
A friendly guide to EF Core bulk updates with ExecuteUpdate and ExecuteDelete. Change many rows in one fast SQL trip, plus the traps to avoid.
The Correct Way to Use Batch Update and Batch Delete in EF Core
Learn the correct, safe way to use ExecuteUpdate and ExecuteDelete batch methods in EF Core, with transactions, change tracker tips, and EF Core 10 features.