Skip to main content
SEMastery
Data Accessbeginner

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.

11 min readUpdated April 11, 2026

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.

Saving one row at a time means one database trip per row

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 batches

Two things changed, and both matter:

  1. We used AddRange instead of calling Add inside the loop. AddRange adds the whole list together, so EF Core does its change detection one time, not 100,000 times.
  2. 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

Add in loop
Save each time
AddRange
Save once

Steps

1

Add in loop

slow, many trips

2

Save each time

one trip per row

3

AddRange

add list together

4

Save once

batched trips

The first and easiest speed-up

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.

SettingWhat it doesWhen to use it
AddRangeAdds a list in one stepAlways, for groups of rows
AutoDetectChangesEnabled = falseStops re-checking objectsDuring large inserts
MaxBatchSizeControls rows per SQL batchWhen tuning large saves
Single SaveChangesOne save for the whole listAlways, 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.

Normal save versus bulk copy on SQL Server

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 SqlBulkCopy streams the whole table into the Products table on the server.
  • BatchSize controls 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

List
DataTable
SqlBulkCopy
Table

Steps

1

List

your C# objects

2

DataTable

in-memory table

3

SqlBulkCopy

stream to server

4

Table

rows saved fast

Three clear steps from list to fast insert

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 rowsBest methodWhy
Up to ~1,000AddRange + one SaveChangesSimple, fast enough, keeps EF features
~1,000 to ~10,000AddRange + batching + no auto-detectStill simple, tuned for size
10,000 to millionsSqlBulkCopy or a bulk librarySkips tracker, uses bulk-copy speed
Set-based changesExecuteUpdate / ExecuteDeleteNo 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 simple decision guide for choosing an insert method

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 AddRange to add a whole list, then call SaveChanges() once. EF Core batches the rows for you.
  • Turn off AutoDetectChangesEnabled during big inserts for a free speed boost.
  • For tens of thousands or millions of rows, use SqlBulkCopy or 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

Related Posts