How I Made My EF Core Query Faster With Batching
A beginner-friendly guide to EF Core batching. Learn how SaveChanges groups SQL into fewer database trips, how to tune MaxBatchSize, and when it helps.
The tea stall and the tray
Picture a busy tea stall near a railway station. A waiter takes orders from a long table of customers.
One waiter carries a single cup, walks all the way to the table, sets it down, walks back, picks up the next cup, walks again, and again. For twenty cups he makes twenty long trips. He is exhausted and the tea goes cold.
A smarter waiter loads a tray with many cups at once. He makes one walk to the table for the whole tray. Same cups delivered, far fewer trips, and the tea is still hot.
EF Core works the same way. The slow part of saving data is usually not the database doing the work. It is the back-and-forth trips between your app and the database. Each trip is a walk across the room. Batching is the tray. EF Core packs many SQL statements together and carries them over in one trip. In this article we will see how this happens, how to make sure you are using it, and how to tune it gently when you need to.
What a "round trip" really means
When your code talks to a database, it sends a request over the network and then waits for the answer. That whole send-and-wait is one round trip. Even on a fast network, each round trip costs a little time, often a few milliseconds. A few milliseconds sounds tiny. Multiply it by a thousand rows and it becomes seconds of pure waiting.
So the goal is simple. Do the same work, but with fewer round trips. Batching is how EF Core does this for you when you save changes.
The slow way: SaveChanges inside a loop
Here is code that looks harmless. We want to add 1,000 new students to the database.
foreach (var student in newStudents)
{
context.Students.Add(student);
await context.SaveChangesAsync(); // one trip per student
}This works, but it is the single-cup waiter. Every time the loop calls SaveChangesAsync, EF Core sends one INSERT and waits for the reply. For 1,000 students, that is 1,000 separate trips. The database is barely sweating. Your app is just standing there waiting, a thousand times.
The slow loop
Steps
Add 1
first student
Save 1
trip to DB
Add 2
second student
Save 2
another trip
...
1000 trips total
The fast way: add many, save once
Now look at almost the same code, with one small change. We move SaveChangesAsync outside the loop.
foreach (var student in newStudents)
{
context.Students.Add(student); // just remember the change
}
await context.SaveChangesAsync(); // one call, EF Core batches the workThis is the tray. We tell EF Core about all 1,000 students first. Nothing touches the database yet. EF Core just writes the changes down in its notebook (this notebook is called the change tracker). Then we call SaveChangesAsync one time. Now EF Core looks at all 1,000 pending inserts, groups them into batches, and sends each batch in a single trip.
The important idea: Add does not talk to the database. SaveChanges does. Keep SaveChanges out of your loop and batching can do its job.
What batching actually sends
You might think EF Core sends one giant statement. It does not. It still sends normal INSERT, UPDATE, and DELETE statements. The clever part is that it packs many of them into one command and sends that command in one round trip. On modern SQL Server (EF Core 10), inserts can even be combined into multi-row INSERT statements, which is faster still.
Here is the difference in plain terms.
| Approach | SaveChanges calls | Round trips for 1000 inserts | Speed |
|---|---|---|---|
| Save inside loop | 1000 | ~1000 | Very slow |
| Add all, save once (batched) | 1 | A handful | Fast |
| Bulk library (e.g. EF Extensions) | 1 | Even fewer, special path | Fastest for huge data |
The middle row is what you get for free, just by writing your code in the right order. That is the win this whole article is about.
How EF Core decides batch size
EF Core does not put every statement into one endless batch. It has a limit called MaxBatchSize. When the number of statements reaches that limit, EF Core sends the batch and starts a new one.
For SQL Server, the default MaxBatchSize is 42. That number is not random. The speed gains from adding more statements to a batch tend to flatten out past about 40 statements, so EF Core stops there by default. So if you insert 1,000 students, EF Core sends roughly 1000 ÷ 42, which is about 24 batches, instead of 1,000 trips. That is a huge improvement from one tiny code change.
Tuning MaxBatchSize (carefully)
You can change the limit when you set up your DbContext. You usually do not need to, but it is good to know how.
builder.Services.AddDbContext<SchoolDbContext>(options =>
{
options.UseSqlServer(
connectionString,
sqlOptions => sqlOptions
.MaxBatchSize(100)); // allow bigger batches
});There is a real trade-off here, so think of it like loading the tray:
- Batch too small means too many trips. The waiter walks more than he needs to.
- Batch too big means one trip carries a very heavy command. The single command itself now takes longer for the database to chew through, and a failure rolls back more work at once.
The right value depends on your database and your rows. For PostgreSQL, values between 42 and 100 often work nicely. For SQL Server, the default of 42 is a sensible starting point. The golden rule: measure before and after. Do not raise the number just because bigger sounds faster.
| Setting | What it controls | Default (SQL Server) | When to change |
|---|---|---|---|
MaxBatchSize | Most statements per batch | 42 | Tune only after measuring |
MinBatchSize | Smallest count before batching kicks in | 4 | Rarely changed |
| Command timeout | How long to wait per command | Provider default | Raise for very large batches |
A real before-and-after
Let me show a tiny example you can reason about. Imagine each round trip to the database takes about 2 milliseconds of waiting.
- Loop version: 1,000 trips × 2 ms = about 2,000 ms of waiting. Two whole seconds, mostly doing nothing.
- Batched version: about 24 trips × 2 ms = about 48 ms of waiting.
Same 1,000 rows. Same database. The only change was where we called SaveChanges. That is the kind of fix that feels like magic but is really just fewer walks across the room.
The fix in three steps
Steps
Move SaveChanges out
call it once after the loop
Let EF Core batch
grouping happens for free
Measure
check trips and timing
Batching also works for updates and deletes
Batching is not only for inserts. The same idea covers updates and deletes that go through change tracking. If you load a list, change many entities, and call SaveChanges once, EF Core batches all those UPDATE statements together too.
var students = await context.Students
.Where(s => s.Grade == "A")
.ToListAsync();
foreach (var student in students)
{
student.Scholarship = true; // change tracked, no DB call yet
}
await context.SaveChangesAsync(); // all UPDATEs batched into few tripsThere is also a newer, even more direct tool for bulk changes called ExecuteUpdateAsync and ExecuteDeleteAsync. These run a single SQL statement on the server without loading the rows into memory first. They are wonderful when you want to change many rows by a rule (for example, "set scholarship to true for every A-grade student") and you do not need each entity in memory. They are a different tool from change-tracked batching, so keep both in your toolbox.
await context.Students
.Where(s => s.Grade == "A")
.ExecuteUpdateAsync(setters => setters
.SetProperty(s => s.Scholarship, true));How to see batching with your own eyes
Do not just trust that batching is happening. Look. The easiest way is to turn on EF Core logging so you can watch the SQL it sends. Counting the round trips tells you the truth far better than guessing.
builder.Services.AddDbContext<SchoolDbContext>(options =>
{
options.UseSqlServer(connectionString);
options.LogTo(Console.WriteLine, LogLevel.Information);
});When batching works, you will see a few grouped commands in the log instead of a thousand single inserts. If you still see one insert per row, your SaveChanges is probably stuck inside a loop. That log is your proof.
When batching is not the answer
Batching is great, but it is not a magic wand for every problem. A few honest limits:
- Reading data. Batching helps with saving changes. If your read query is slow, batching will not help. For slow reads, look at the N+1 problem, projections, and indexes instead.
- Truly massive loads. If you are pushing hundreds of thousands or millions of rows, even good batching may not be enough. That is when a dedicated bulk library or a database-native bulk copy makes sense. Note that two popular .NET helper libraries, MediatR and MassTransit, are now under commercial licenses, but the EF Core bulk space has its own well-known options like EF Core Extensions; pick based on your needs and budget.
- Many tables in one save. Batching is most effective when many statements hit the same table in the same shape. Mixed work across many tables can break batches into smaller groups.
A simple checklist before you ship
Run through this list whenever a save feels slow:
- Is
SaveChangesoutside the loop? If not, move it out. This is the biggest win. - Are you turning on logging to count round trips? If not, you are guessing.
- Did you try the default
MaxBatchSizefirst before changing it? - For rule-based mass updates, would
ExecuteUpdateAsyncorExecuteDeleteAsyncbe cleaner? - For truly huge loads, is a bulk library the better fit?
Most slow-save problems are solved by step 1 alone. The rest are there for when you have already done the easy thing and still need more.
Putting it all together
Batching is a quiet helper that EF Core already gives you. It does not need new packages or clever tricks. It just needs you to write your code so that EF Core can see all the changes at once and carry them over together. Add many, save once. Watch the logs. Tune only when the numbers tell you to. That is the whole story.
Quick recap
- Each database round trip is a send-and-wait. Fewer trips means faster saves.
- EF Core batches automatically when you call
SaveChangesonce after making many changes. - Calling
SaveChangesinside a loop kills batching and forces one trip per row. Move it out. MaxBatchSizecontrols how many statements go in one batch; the SQL Server default is 42.- Tune
MaxBatchSizeonly after measuring. Too small means too many trips; too big means heavy, slow commands. - Batching covers inserts, updates, and deletes that go through change tracking.
- For rule-based mass changes,
ExecuteUpdateAsyncandExecuteDeleteAsyncrun one server-side statement. - For very large loads (hundreds of thousands of rows), reach for a bulk library or native bulk copy.
- Turn on EF Core logging to see the batches and prove the fix worked.
References and further reading
- Efficient Updating — EF Core (Microsoft Learn)
- Advanced Performance Topics — EF Core (Microsoft Learn)
- ExecuteUpdate and ExecuteDelete — EF Core (Microsoft Learn)
- Bulk Operations in EF Core 10 — codewithmukesh
- Setting batch size in Entity Framework Core — Dave Callan
Related Posts
EF Core Query Optimization: From 30 Seconds to 30 Milliseconds
Learn EF Core query optimization step by step: fix N+1 queries, use projections, AsNoTracking, indexes, and compiled queries to turn a 30-second query into 30ms.
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.
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.
EF Core Query Splitting: Fix Slow Queries and Cartesian Explosion
Learn how EF Core query splitting (AsSplitQuery) fixes the cartesian explosion problem with simple examples, diagrams, and real performance numbers. Know when to split and when not to.
Why Every EF Core Developer Needs to Try Entity Framework Extensions
A friendly guide to Entity Framework Extensions: bulk insert, update, delete and merge for EF Core, with simple analogies, diagrams, tables and code examples.
Unleash EF Core Performance With Compiled Queries
Learn EF Core compiled queries in .NET 10 with EF.CompileQuery and EF.CompileAsyncQuery. Simple words, real examples, and clear before-and-after numbers.