Chapter 12 · Saving Data: Insert, Update, Delete, Batching, ExecuteUpdate, and ExecuteDelete
Batching Inserts and Updates, Parameter Limits, Round Trips, and Provider Thresholds
Measure relational batching as a provider capability, distinguish statement count from round trips, respect parameter/database limits, and avoid treating SaveChanges as a native bulk loader.
Learning outcomes
A team sees “EF batches writes” and assumes 5,000 inserts equal one bulk load. That is a category error. Batching is a provider optimization that can send multiple modification statements with fewer round trips; it does not turn tracked per-row persistence into a database-native loader, erase parameter/statement limits, or make one batch size optimal across engines.
Distinguish EF modification-command batching from native bulk ingestion.
Measure command count, batch boundaries, parameters, and round trips from logs instead of guessing.
Explain provider-specific MinBatchSize/MaxBatchSize behavior without universal tuning folklore.
Account for database parameter/statement limits and row shape when sizing write workloads.
Compare one SaveChanges call with SaveChanges-inside-a-loop and diagnose round-trip amplification.
Establish a repeatable benchmark disclosure before changing batch configuration.
Mandatory labs use .NET SDK 10.0.400, .NET runtime 10.0.11, Microsoft.EntityFrameworkCore/SQLite 10.0.11, dotnet-ef 10.0.11, the disposable servicehub-lab.db, deterministic ServiceHub seed data, and no paid tooling. SQL Server/PostgreSQL native-loader examples are optional provider comparisons. EF Core 11 previews are excluded from the mandatory path.
1. Statement count and round trips are different dimensions
| Pattern | Tracked? | Typical command shape | Key risk |
|---|---|---|---|
| One SaveChanges for N entities | Yes | Many INSERT/UPDATE statements, provider may group them. | Tracker/materialization + per-row DML still exists. |
| SaveChanges inside loop | Yes | Repeated batches/transactions. | Round-trip and transaction amplification. |
| ExecuteUpdate/Delete | No tracker sync | One set-based UPDATE/DELETE per invocation. | Immediate write; stale tracker/concurrency manual. |
| Native loader | Usually outside EF tracker | Provider protocol/API optimized for ingestion. | Bypasses EF conventions/events unless recreated explicitly. |
Always log both database commands and transaction boundaries. A
log containing 100 SQL statements does not by itself prove 100
network round trips; the provider may batch statements.
Conversely, one SaveChanges does not prove one
physical request on every provider/workload.
2. The wrong baseline: SaveChanges in the loop
foreach (var item in importRows){ db.Add(WorkOrder.Open(item.Number, item.Summary, item.Address)); await db.SaveChangesAsync(ct); // repeated save/transaction boundary}
foreach (var item in importRows){ db.Add(WorkOrder.Open(item.Number, item.Summary, item.Address));}await db.SaveChangesAsync(ct);
The repair reduces application-level SaveChanges boundaries and gives the provider a larger set of commands to batch. It is still not an invitation to track millions of entities in one context; memory, DetectChanges, parameter limits, transaction duration, locks, and failure recovery become workload constraints.
3. Provider thresholds are examples, not universal constants
Microsoft documents SQL Server-specific batching behavior and
exposes provider options such as
MinBatchSize/MaxBatchSize. The
documented SQL Server defaults/heuristics must not be copied to
SQLite, PostgreSQL, MySQL, or Oracle as folklore. For the
mandatory SQLite lab, observe the actual provider logs and
database build limits instead of forcing SQL Server settings.
options.UseSqlServer(connectionString, sql =>{ // Example tuning knobs; benchmark your workload before changing defaults. sql.MinBatchSize(1); sql.MaxBatchSize(64);});
Do not place these SQL Server provider options in the mandatory SQLite configuration. The lesson uses them only to teach that batching controls are provider-specific.
4. Parameter limits depend on row shape and engine/provider
A batch with 20 wide rows can contain more parameters than a batch with 200 narrow rows. Limits can come from the database engine, SQL grammar/packet limits, ADO.NET provider, EF provider, and generated statement shape. The robust workflow is: capture generated commands → count parameters/statements → inspect provider/database limits → benchmark representative sizes.
PRAGMA compile_options;
If the output exposes a variable-number compile option, record it as evidence for that SQLite build. Do not hard-code it into portable application logic. Provider batching can also split work before an engine limit is reached.
5. Measure with an interceptor instead of counting console lines by eye
public sealed class CommandCounter : DbCommandInterceptor{ private long _commands; public long Commands => Interlocked.Read(ref _commands); public override InterceptionResult<int> NonQueryExecuting( DbCommand command, CommandEventData eventData, InterceptionResult<int> result) { Interlocked.Increment(ref _commands); Console.WriteLine($"SQL params={command.Parameters.Count}"); return result; }}
One interceptor callback is evidence about executed commands, not necessarily an end-to-end network benchmark. For real performance work also disclose warm-up, Debug/Release, logging level, data size, indexes, transaction scope, machine/container, network topology, and database metrics.
6. Reproducible write-shape experiment
foreach (var size in new[] { 1, 10, 100, 500 }){ await ResetLabDatabaseAsync(ct); await using var db = await factory.CreateDbContextAsync(ct); for (var i = 0; i < size; i++) { db.Add(WorkOrder.Open( $"WO-BATCH-{size:D4}-{i:D5}", $"Batch observation row {i}", new ServiceAddress("1 Batch Lab Rd", "Baku", "AZ", "AZ1000", "AZ"))); } var started = Stopwatch.GetTimestamp(); await db.SaveChangesAsync(ct); var elapsed = Stopwatch.GetElapsedTime(started); Console.WriteLine($"size={size}; elapsed={elapsed}");}
Do not publish these timings as universal EF performance. They are a local experiment whose purpose is to reveal growth, command/batch shape, and the point at which tracked persistence no longer fits the workload.
7. Hands-on lab and acceptance checks
- Run 1/10/100/500-row insert tests against a reset SQLite file with command logging.
-
Repeat 100 rows with
SaveChangesAsyncinside the loop; compare command/transaction counts and elapsed time. -
Capture parameter counts for representative statements and
record
PRAGMA compile_options. - Repeat a 100-row update as tracked mutations and record command shape.
- Do not change provider batch knobs for SQLite merely because the SQL Server docs expose them.
- Document machine, Release/Debug configuration, logging, data size, provider/database versions, and whether the database is local.
Check your understanding
- Is one SaveChanges call equivalent to one SQL statement?
- Is EF batching a native bulk loader?
- Why is SaveChanges in a loop usually a poor baseline?
- Can SQL Server batch thresholds be copied to SQLite?
- What determines parameter pressure?
- What must accompany a performance number?
Review the answers
No. It can contain many modification commands and provider batches.
No. It still represents tracked per-entity modification commands.
It multiplies save/transaction/round-trip boundaries and defeats batching opportunities.
No. Provider/database behavior is different; measure the target provider.
Row count, mapped write shape, database/provider limits, and generated SQL structure.
Versions, build mode, hardware/topology, data shape/size, logging, transaction scope, indexes, warmup/cache state, and measurement method.
8. Production judgment and bridge
Batching is a useful optimization inside tracked persistence,
not an ingestion architecture. Keep ordinary aggregate writes on
SaveChanges; when the business operation is
naturally “update every matching row” or “delete every matching
row,” Lesson 3 shows the more appropriate set-based EF APIs.
Authoritative references
- Efficient Updating - EF Core — automatic batching and SQL Server provider thresholds.
- Saving Data - EF Core — tracked versus set-based write approaches.
- Interceptors - EF Core — command interception for observation.
- Performance - EF Core — measurement-first performance guidance.
- Limits in SQLite — engine limits that vary by build/version.