Skip to main content
SEMastery
Data Accessbeginner

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.

11 min readUpdated February 4, 2026

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.

One round trip is a send-and-wait between your app and the database.

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

Add 1
Save 1
Add 2
Save 2
...

Steps

1

Add 1

first student

2

Save 1

trip to DB

3

Add 2

second student

4

Save 2

another trip

5

...

1000 trips total

Saving inside the loop forces one round trip per row.

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 work

This 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.

Adding fills the change tracker; SaveChanges sends grouped batches.

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.

ApproachSaveChanges callsRound trips for 1000 insertsSpeed
Save inside loop1000~1000Very slow
Add all, save once (batched)1A handfulFast
Bulk library (e.g. EF Extensions)1Even fewer, special pathFastest 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.

Statements fill a batch up to the limit, then the batch is sent.

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.

SettingWhat it controlsDefault (SQL Server)When to change
MaxBatchSizeMost statements per batch42Tune only after measuring
MinBatchSizeSmallest count before batching kicks in4Rarely changed
Command timeoutHow long to wait per commandProvider defaultRaise 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

Move SaveChanges out
Let EF Core batch
Measure

Steps

1

Move SaveChanges out

call it once after the loop

2

Let EF Core batch

grouping happens for free

3

Measure

check trips and timing

A repeatable recipe for faster saves.

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 trips

There 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.
Pick the right tool for the size of the job.

A simple checklist before you ship

Run through this list whenever a save feels slow:

  1. Is SaveChanges outside the loop? If not, move it out. This is the biggest win.
  2. Are you turning on logging to count round trips? If not, you are guessing.
  3. Did you try the default MaxBatchSize first before changing it?
  4. For rule-based mass updates, would ExecuteUpdateAsync or ExecuteDeleteAsync be cleaner?
  5. 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 SaveChanges once after making many changes.
  • Calling SaveChanges inside a loop kills batching and forces one trip per row. Move it out.
  • MaxBatchSize controls how many statements go in one batch; the SQL Server default is 42.
  • Tune MaxBatchSize only 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, ExecuteUpdateAsync and ExecuteDeleteAsync run 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

Related Posts