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.
Think about filling a water tank with a small cup. You dip the cup in a bucket, walk to the tank, pour it in, and walk back. For one glass of water this is fine. But filling a big tank one cup at a time will take you all afternoon, and your arms will hurt.
Now imagine someone hands you a thick pipe instead. You connect the pipe once and the water rushes in. Same tank, same water, but it fills in seconds.
Saving data in your app works the same way. Saving rows one at a time is the small cup. A bulk insert is the thick pipe. In this post you will learn how to do fast SQL bulk inserts in C# with Entity Framework Core (EF Core). We will start simple, see why the slow way is slow, and then move step by step to the fast ways.
What happens when you save one row at a time
EF Core is an ORM. That means you work with normal C# objects like Product or Order, and EF Core writes the SQL for you. This is very nice for everyday code. But it does some hidden work that you should understand.
When you add an object, EF Core writes it down in a change tracker. The change tracker is like a notebook. It remembers every object you added, changed, or deleted. When you call SaveChanges(), EF Core reads that notebook and builds SQL commands.
Here is the slow way that beginners often write first.
using var db = new AppDbContext();
for (int i = 0; i < 100_000; i++)
{
var product = new Product { Name = $"Item {i}", Price = i };
db.Products.Add(product); // adds one
db.SaveChanges(); // saves one, then talks to the database
}Look closely at the loop. We call SaveChanges() inside the loop. That means EF Core talks to the database 100,000 separate times. Every trip to the database has a cost, even a tiny one. Multiply that tiny cost by 100,000 and you get a very slow program.
This is the small cup. It works, but it walks back and forth far too many times.
First fix: AddRange and one SaveChanges
The easiest improvement is simple. Add all the rows first, then save once. EF Core is smart. When you call SaveChanges() one time with many rows, it groups the rows into batches and sends them together.
using var db = new AppDbContext();
var products = new List<Product>();
for (int i = 0; i < 100_000; i++)
{
products.Add(new Product { Name = $"Item {i}", Price = i });
}
db.Products.AddRange(products); // add the whole list at once
db.SaveChanges(); // one save, sent in batchesTwo things changed, and both matter:
- We used
AddRangeinstead of callingAddinside the loop.AddRangeadds the whole list together, so EF Core does its change detection one time, not 100,000 times. - We called
SaveChanges()only once, outside the loop. EF Core then packs the rows into batches behind the scenes.
This one change can turn a long wait into a short one. For many apps, this is all you ever need.
From slow loop to single save
Steps
Add in loop
slow, many trips
Save each time
one trip per row
AddRange
add list together
Save once
batched trips
Turn off change detection for an extra boost
EF Core watches your objects for changes. This watching is called change detection. It is helpful in normal code, but during a big insert you do not need it. You can switch it off while you load the data, then save.
using var db = new AppDbContext();
db.ChangeTracker.AutoDetectChangesEnabled = false;
var products = new List<Product>();
for (int i = 0; i < 100_000; i++)
{
products.Add(new Product { Name = $"Item {i}", Price = i });
}
db.Products.AddRange(products);
db.SaveChanges();Turning off AutoDetectChangesEnabled tells EF Core to stop checking objects for changes during the loop. Since we are only adding brand new rows, there is nothing to re-check. This gives you a free speed boost with one line.
You can also tune the batch size. EF Core sends rows in groups, and you can hint how big each group should be when you set up the connection.
| Setting | What it does | When to use it |
|---|---|---|
AddRange | Adds a list in one step | Always, for groups of rows |
AutoDetectChangesEnabled = false | Stops re-checking objects | During large inserts |
MaxBatchSize | Controls rows per SQL batch | When tuning large saves |
Single SaveChanges | One save for the whole list | Always, never save in a loop |
When SaveChanges is still too slow
Even with all those tricks, EF Core still builds a SQL command for every row and still tracks each one. For 1,000 or even 10,000 rows that is fine. But when you reach hundreds of thousands or millions of rows, the change tracker gets heavy and memory use climbs.
This is where we reach for a real bulk insert. A bulk insert skips the change tracker completely. On SQL Server, it uses a special fast path called the bulk-copy protocol. Instead of sending many small commands, it streams the rows straight into the table.
The bulk-copy path is the thick pipe from our water tank story. The water still goes into the same tank, but it rushes in instead of trickling.
Using SqlBulkCopy directly
SqlBulkCopy is built into the SQL Server data library (Microsoft.Data.SqlClient). It is the engine that powers most fast inserts. It does not know about EF Core at all. It just takes a table of data and pushes it into a database table very quickly.
SqlBulkCopy reads from a DataTable or a data reader. Here is a small helper that copies a list of products into the database.
using Microsoft.Data.SqlClient;
using System.Data;
void BulkInsertProducts(List<Product> products, string connectionString)
{
// 1. Build an in-memory table that matches the database columns
var table = new DataTable();
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Price", typeof(decimal));
foreach (var p in products)
{
table.Rows.Add(p.Name, p.Price);
}
// 2. Stream the table into SQL Server
using var connection = new SqlConnection(connectionString);
connection.Open();
using var bulkCopy = new SqlBulkCopy(connection)
{
DestinationTableName = "Products",
BatchSize = 5000
};
bulkCopy.WriteToServer(table);
}Let us read this slowly:
- First we make a
DataTable. Think of it as a spreadsheet in memory with the same columns as the real table. - We fill the rows from our product list.
- Then
SqlBulkCopystreams the whole table into theProductstable on the server. BatchSizecontrols how many rows go in each push. A value of a few thousand is a good starting point.
This is the fastest common way to insert big data on SQL Server. The trade-off is that you write more code, and you lose EF Core features like the change tracker and automatic IDs during the copy.
One more tip: the column names and types in your DataTable must match the real table. If a column is missing or has the wrong type, SqlBulkCopy will throw an error. You can also add column mappings with bulkCopy.ColumnMappings.Add when the names do not line up exactly. Start with a small test of a few rows to make sure the mapping is correct before you push a million rows.
How SqlBulkCopy works
Steps
List
your C# objects
DataTable
in-memory table
SqlBulkCopy
stream to server
Table
rows saved fast
Using a bulk library with EF Core
Writing DataTable code by hand is fine, but it is repetitive. A nicer option is a library that adds a bulk method right onto your DbContext. A popular free and open-source choice is EFCore.BulkExtensions. It works on SQL Server, PostgreSQL, MySQL, SQLite, and more. Under the hood it uses SqlBulkCopy on SQL Server and the matching fast protocol on other databases.
With it, the code stays close to normal EF Core but runs far faster.
using EFCore.BulkExtensions;
using var db = new AppDbContext();
var products = new List<Product>();
for (int i = 0; i < 1_000_000; i++)
{
products.Add(new Product { Name = $"Item {i}", Price = i });
}
// One call, no change tracker, uses bulk-copy under the hood
await db.BulkInsertAsync(products);That single BulkInsertAsync call handles the DataTable work for you and streams the rows in. You keep your EF Core models and just swap the save call. There is also a commercial library called Entity Framework Extensions with more features and a free trial; pick whichever fits your budget and needs.
Picking the right method
There is no single best answer. The right tool depends on how many rows you insert and what features you need. This table gives you a simple rule of thumb.
| Number of rows | Best method | Why |
|---|---|---|
| Up to ~1,000 | AddRange + one SaveChanges | Simple, fast enough, keeps EF features |
| ~1,000 to ~10,000 | AddRange + batching + no auto-detect | Still simple, tuned for size |
| 10,000 to millions | SqlBulkCopy or a bulk library | Skips tracker, uses bulk-copy speed |
| Set-based changes | ExecuteUpdate / ExecuteDelete | No need to load rows at all |
A quick note on the last row. If you want to change or delete many rows that already exist, you do not always need to load them first. EF Core has ExecuteUpdate and ExecuteDelete that run one SQL command directly. They are not inserts, but they are part of the same family of fast bulk tools, so they are good to know.
A few safety tips
Fast inserts are great, but keep these points in mind so you do not get surprised.
- Wrap big inserts in a transaction. If something fails halfway, a transaction lets you undo the whole batch cleanly instead of leaving half the rows saved.
- Watch your memory. Building a list of one million objects uses real memory. If the data is huge, read and insert it in chunks instead of all at once.
- Bulk insert skips some EF features. During a bulk copy, the change tracker is not used, and database-generated IDs may not flow back into your objects unless the library supports it. Read the library docs before you depend on those values.
- Test with real numbers. Always measure with a size close to your real data. A method that wins at 100 rows can lose at one million rows, and the other way around.
Quick recap
- Never call
SaveChanges()inside a loop. That makes one database trip per row and is very slow. - Use
AddRangeto add a whole list, then callSaveChanges()once. EF Core batches the rows for you. - Turn off
AutoDetectChangesEnabledduring big inserts for a free speed boost. - For tens of thousands or millions of rows, use
SqlBulkCopyor a bulk library like EFCore.BulkExtensions. These use the database bulk-copy protocol and skip the change tracker. - Choose your method based on row count and the features you need. Small inserts stay simple; huge inserts go fast.
- Use transactions, watch memory, and always measure with real-sized data.
References and further reading
- Microsoft Learn - SqlBulkCopy Class
- Microsoft Learn - EF Core Efficient Updating
- EFCore.BulkExtensions on GitHub
- Fast SQL Bulk Inserts With C# and EF Core - Milan Jovanovic
- Bulk Operations in EF Core 10 - codewithmukesh
Related Posts
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.
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.
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.
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.
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.