Skip to main content

How to Add Several Rows at Once with EF Core AddRange

· 5 min read
Jagdish Kumawat
Founder @ Dewiride

AddRange adds a list of new objects to the change tracker, and one SaveChangesAsync inserts them all. With Oracle's MySQL provider, EF Core packs the INSERT statements into one command to save round trips, and every object gets its own ID. This guide shows the code and the SQL it sends for 3 rows and for 50.

Prerequisites​

Step 1: Add the List and Save​

A temporary endpoint that adds several categories
app.MapPost("/demo/addrange", async (int count, InventoryDbContext db, TimeProvider clock) =>
{
var now = clock.GetUtcNow().UtcDateTime;
var categories = Enumerable.Range(1, count)
.Select(i => new Category { Name = $"Batch {now:HHmmss} {i}", CreatedAtUtc = now })
.ToList();

db.Categories.AddRange(categories);
var saved = await db.SaveChangesAsync();

return Results.Ok(new { saved, ids = categories.Select(c => c.Id) });
});

AddRange does the same as calling Add for each one. SaveChangesAsync returns how many rows it wrote. .ToList() matters: it makes one list of objects, so categories afterwards holds the same objects that got their IDs.

Step 2: Read the Result​

For three rows:

Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
"saved": 3,
"ids": [
4,
5,
6
]
}
API log
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (2ms) [Parameters=[@p0='?' (DbType = DateTime), @p1='?' (Size = 100), @p2='?' (DbType = DateTime), @p3='?' (Size = 100), @p4='?' (DbType = DateTime), @p5='?' (Size = 100)], CommandType='Text', CommandTimeout='30']
INSERT INTO `categories` (`CreatedAtUtc`, `Name`)
VALUES (@p0, @p1);
SELECT `Id`
FROM `categories`
WHERE ROW_COUNT() = 1 AND `Id` = LAST_INSERT_ID();
INSERT INTO `categories` (`CreatedAtUtc`, `Name`)
VALUES (@p2, @p3);
SELECT `Id`
FROM `categories`
WHERE ROW_COUNT() = 1 AND `Id` = LAST_INSERT_ID();
INSERT INTO `categories` (`CreatedAtUtc`, `Name`)
VALUES (@p4, @p5);
SELECT `Id`
FROM `categories`
WHERE ROW_COUNT() = 1 AND `Id` = LAST_INSERT_ID();

A terminal with a 200 OK response with saved 3 and three IDs, and the API log with one command holding three pairs of INSERT and SELECT statements.

One Executed DbCommand, holding three INSERT statements, each followed by the SELECT that reads its ID. So three rows took one trip to MySQL. EF Core's efficient updating page calls this batching: "EF Core helps minimize roundtrips by automatically batching together all updates in a single roundtrip."

Step 3: See How Large Batches Split​

With 50 rows, the log has two commands. I counted the INSERT statements in each:

CommandINSERT statements
First42
Second8

The same page explains the number for SQL Server: "EF Core will by default only execute up to 42 statements in a single batch, and execute additional statements in separate roundtrips." These 42 and 8 show Oracle's MySQL provider doing the same. You can change the size with MaxBatchSize, in the options you pass to UseMySQL, though there's rarely a reason to.

Is This All-or-Nothing?​

By default, yes. EF Core's transactions page says: "if the database provider supports transactions, all changes in a single call to SaveChanges are applied in a transaction. If any of the changes fail, then the transaction is rolled back and none of the changes are applied to the database." So one bad row in the list means none are stored, and you get one exception. DbUpdateException handling works the same as for a single row, as this post shows.

Common Mistakes​

MistakeWhat happensDo this instead
Calling SaveChangesAsync inside the loopOne round trip and one transaction per rowAdd them all, then save once
Leaving out .ToList()The Select runs again when you read the IDs, making new objects with Id 0Materialize the list first
Using this for millions of rowsChange tracking and thousands of statements get slowUse MySQL's bulk tools, such as LOAD DATA
Expecting one INSERT with many VALUESWith this provider, it's one INSERT per row in the batchRead the log before assuming

FAQ​

Does AddRange send anything to MySQL?​

No. Like Add, it only tells the change tracker. SaveChangesAsync sends the SQL.

Do I get all the IDs?​

Yes. Each INSERT has its own SELECT, so each object gets its own Id, as the response above shows.

Conclusion​

db.Categories.AddRange(list) and one SaveChangesAsync insert a whole list in one transaction, in batches of up to 42 statements, and fill in each object's Id. Call SaveChangesAsync once, after adding everything.

Additional Resources​

Stay Updated

Subscribe to our newsletter for the latest tutorials, tech insights, and developer news.

By subscribing, you agree to our privacy policy. Unsubscribe at any time.