The fastest way to bulk insert thousands of rows in EF Core (Entity Framework Core) is to stop asking EF Core to do the insert. For serious volume, drop to your provider’s bulk-copy path: PostgreSQL’s binary COPY or SQL Server’s SqlBulkCopy. For a few thousand rows, AddRange plus a single SaveChanges is plenty. The thing to internalize up front: EF Core 10 has no built-in bulk insert API, so throughput is always a choice about how far past the change tracker you’re willing to go.
I benchmarked six insert strategies on .NET 10 and EF Core 10 against PostgreSQL 17, from the naive loop everyone starts with to raw binary COPY. The gap between the slowest and the fastest is not a few percent. It is more than two orders of magnitude. Let’s get into what’s actually happening and which approach fits which job.
TL;DR. EF Core has no
ExecuteInsert- inserts always go throughAdd/AddRange+SaveChanges. The killer mistake is callingSaveChangesinside the loop (one round trip per row). UseAddRange+ oneSaveChangesfor up to a few thousand rows. Turn offAutoDetectChangesEnabledand chunk withChangeTracker.Clear()for tens of thousands. For real throughput, use a bulk library likeEFCore.BulkExtensionsor Entity Framework Extensions (BulkInsert,BulkMerge), or go straight to the raw provider path - Npgsql binaryCOPYorSqlBulkCopy- which skip parameter binding and the change tracker entirely. Watch the parameter ceiling: 2,100 on SQL Server, 65,535 on PostgreSQL.
The complete, runnable sample - a BenchmarkDotNet project plus a Web API with three import endpoints - is in the GitHub repo. Clone it, run docker compose up, and reproduce every number below.
CRUD with EF Core in ASP.NET Core
New to EF Core 10? Start here. This covers setting up EF Core with PostgreSQL, entities, migrations, and a full CRUD API before you optimize inserts.
Does EF Core Have a Built-In Bulk Insert?
No. EF Core 10 has no bulk insert API. It added ExecuteUpdate and ExecuteDelete back in EF Core 7 for set-based updates and deletes, but the official documentation is explicit that inserts are not included: “Only updating and deleting is currently supported; insertion must be done via DbSet<TEntity>.Add and SaveChanges().”
That single fact shapes everything. Every native insert in EF Core runs through the change tracker: you create entity instances, EF tracks them, and SaveChanges turns tracked Added entities into SQL. That machinery is exactly what you want for a normal request that saves one order. It is pure overhead when you’re pushing 50,000 rows from a CSV import. The strategies below are really a ladder of how much of that machinery you’re willing to give up for speed.
Why Is My EF Core Bulk Insert So Slow?
The single most common reason is calling SaveChanges() inside the loop. Each call is a separate database round trip, so inserting 10,000 rows becomes 10,000 sequential round trips.
// The slowest possible way to insert. Do not do this.foreach (var product in products){ context.Products.Add(product); await context.SaveChangesAsync(ct); // one round trip PER ROW}On a local database each round trip is cheap, maybe a fraction of a millisecond. Multiply by 10,000 and add network latency to a real database server, and this is the code that turns a two-second import into a five-minute one. This is the mistake that hides in code review because it looks reasonable and passes every small-data test.
The fix is to move SaveChanges outside the loop so EF Core can batch:
foreach (var product in products){ context.Products.Add(product);}await context.SaveChangesAsync(ct); // one call, batched round tripsIs AddRange Faster Than a Foreach Loop?
Slightly, and it reads better, but this is where a lot of advice is stuck in 2015. In modern EF Core, DbSet.Add in a loop is close to AddRange because Add only runs local change detection on the single entity graph you just added, not a full sweep of the tracker. The dramatic “AddRange is 10x faster than Add-in-a-loop” claim is an EF6-era artifact. In EF Core 10 the real killer is SaveChanges in the loop, not Add in the loop.
So use AddRange because it’s cleaner and marginally faster, not because it’s a magic speed switch:
context.Products.AddRange(products);await context.SaveChangesAsync(ct);Tracking vs. No-Tracking Queries in EF Core 10
Bulk insert speed is a change-tracker story. This explains how tracking works, the memory it costs, and why untracked paths win at scale.
What Is the Right Way to Insert with Native EF Core?
Use AddRange followed by a single SaveChanges, and let EF Core’s batching do the work. When you call SaveChanges, EF Core does not send one statement per row. It groups multiple inserts into a single batched command and one round trip.
The batch size is capped, and the default is provider-specific - this trips people up constantly:
- SQL Server: default
MaxBatchSizeis 42. Batching benefits degrade past ~40 statements on SQL Server, so EF Core caps it there. - PostgreSQL (Npgsql): default
MaxBatchSizeis 1,000, not 42. The “42” figure you see repeated online is SQL-Server-only.
The generated SQL also differs by provider. PostgreSQL uses INSERT ... RETURNING to send many rows in one command and read back generated keys. SQL Server has used INSERT ... OUTPUT since EF Core 7 (it dropped the older MERGE-based technique, keeping MERGE only as a fallback for tables with triggers). You can tune the cap when you need to:
builder.Services.AddDbContext<AppDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.MaxBatchSize(1000)));Bigger batches mean fewer round trips but larger commands and more parameters per command, which runs you into the parameter ceiling faster (more on that below). Benchmark before changing it.
Configuring Entities with Fluent API in EF Core 10
Keys, column types, precision, and indexes all shape how a bulk insert generates SQL. Get your entity configuration solid before you tune throughput.
Should I Turn Off AutoDetectChangesEnabled?
For a large insert-only hot path, yes, but treat it as a minor win, not the main event. AutoDetectChangesEnabled = false stops EF Core from running its automatic change-detection sweep on the many API calls that would otherwise trigger it. On a tight insert loop that removes repeated work.
context.ChangeTracker.AutoDetectChangesEnabled = false;context.Products.AddRange(products);await context.SaveChangesAsync(ct);The caveat: while it’s off, EF Core won’t automatically notice property changes on tracked entities, so this is for insert-only paths. Re-enable it (or call DetectChanges() yourself) if you go on to modify tracked entities in the same context.
How Do I Insert Tens of Thousands of Rows Without Running Out of Memory?
Chunk the insert and call ChangeTracker.Clear() between chunks. Even with AddRange, every entity you add stays tracked in memory until the context is cleared or disposed. Add 100,000 entities to one context and you’re holding 100,000 tracked objects plus their change-tracking snapshots. On a memory-limited container that’s an OutOfMemoryException waiting to happen.
context.ChangeTracker.AutoDetectChangesEnabled = false;
foreach (var chunk in products.Chunk(5000)){ context.Products.AddRange(chunk); await context.SaveChangesAsync(ct); context.ChangeTracker.Clear(); // release tracked entities before the next chunk}Chunk sizes of 1,000 to 10,000 are a reasonable starting range - tune it for your row width. This keeps memory flat and stays inside the native EF Core world, which means you keep generated IDs and interceptors. It is the sweet spot for imports that are large but not enormous.
10 EF Core Performance Mistakes (and How to Fix Them)
Slow inserts are one of a family of EF Core performance traps. This covers the other nine, each with a fix and a benchmark.
Why Does My Bulk Insert Fail at Scale but Work with Small Data?
The usual culprit is the database’s parameter limit per command. Every value in a batched EF Core insert becomes a query parameter, and both major providers cap how many parameters a single command can carry:
- SQL Server: 2,100 parameters per command. The error reads: “The incoming request has too many parameters. The server supports a maximum of 2100 parameters.”
- PostgreSQL: 65,535 parameters per command, because the wire protocol encodes the parameter count as a 16-bit integer.
Here’s the trap. A batched insert uses roughly rows × columns-per-row parameters. On a wide table this adds up fast. A 60-column table at just 40 rows per batch is already 2,400 parameters, over the SQL Server limit. The insert works perfectly in tests with 5 rows and throws in production with 50. This is the exact reason SQL Server’s default batch cap is a conservative 42.
The raw provider paths in the next section sidestep this completely, because COPY and SqlBulkCopy stream data instead of binding parameters. A few other failure modes worth knowing:
- Command timeouts. The default
CommandTimeoutis 30 seconds. A large import can blow past it. Raise it withcontext.Database.SetCommandTimeout(120)for import paths. - One giant transaction. Wrapping 100,000 rows in a single transaction bloats the database’s write-ahead log, holds locks longer, and grows memory. Chunked commits are usually healthier than one atomic megatransaction.
- Duplicate keys. A unique-constraint collision aborts the batch. De-duplicate before inserting, or use an upsert (below).
Global Exception Handling in ASP.NET Core
Bulk imports fail in messy ways - constraint violations, timeouts, deadlocks. Centralized exception handling turns those into clean API responses instead of 500s.
What Is the Absolute Fastest Way to Bulk Insert?
Use the database’s native bulk-copy protocol and skip EF Core for the write. On PostgreSQL that’s binary COPY; on SQL Server it’s SqlBulkCopy. Both stream rows in the database’s own format with no per-row parameter binding and no change tracking, which is why they beat everything else by a wide margin.
PostgreSQL: Npgsql Binary COPY
Npgsql’s binary import is the fastest way to load data into PostgreSQL, roughly 3x faster than plain INSERT. You write rows straight into a COPY stream:
await using var connection = new NpgsqlConnection(connectionString);await connection.OpenAsync(ct);
await using var writer = await connection.BeginBinaryImportAsync( "COPY \"Products\" (\"Name\", \"Sku\", \"Price\", \"Category\", \"CreatedAtUtc\") FROM STDIN (FORMAT BINARY)", ct);
foreach (var product in products){ await writer.StartRowAsync(ct); await writer.WriteAsync(product.Name, NpgsqlDbType.Varchar, ct); await writer.WriteAsync(product.Sku, NpgsqlDbType.Varchar, ct); await writer.WriteAsync(product.Price, NpgsqlDbType.Numeric, ct); await writer.WriteAsync(product.Category, NpgsqlDbType.Varchar, ct); await writer.WriteAsync(product.CreatedAtUtc, NpgsqlDbType.TimestampTz, ct);}
await writer.CompleteAsync(ct); // MANDATORY - skip this and the whole import rolls backThe one gotcha that will cost you an afternoon: CompleteAsync() is required. If you dispose the writer without calling it, Npgsql treats the import as failed and rolls everything back. No error, no rows, just an empty table.
Soft Deletes in EF Core 10
Raw COPY and SqlBulkCopy skip the change tracker, so interceptor-driven soft deletes never fire. Here's how the soft-delete pattern actually works on tracked paths.
SQL Server: SqlBulkCopy
SqlBulkCopy is the SQL Server equivalent, and it has been the fastest bulk insert on SQL Server for years. Use the actively maintained Microsoft.Data.SqlClient package, not the legacy System.Data.SqlClient:
using Microsoft.Data.SqlClient;
using var bulk = new SqlBulkCopy(connectionString){ DestinationTableName = "dbo.Products", BatchSize = 10_000, BulkCopyTimeout = 120};bulk.ColumnMappings.Add("Name", "Name");bulk.ColumnMappings.Add("Sku", "Sku");bulk.ColumnMappings.Add("Price", "Price");bulk.ColumnMappings.Add("Category", "Category");
await bulk.WriteToServerAsync(dataTable, ct); // DataTable or IDataReaderTwo notes. Set the column mappings explicitly; without them the mapping is positional and silently wrong if your column order shifts. And SqlBulkCopyOptions.TableLock enables minimal logging on a table you own, which is a real speed boost for large loads. The tradeoff for both raw paths: they don’t hand you generated IDs back, and they bypass interceptors, so audit stamping and soft-delete logic won’t fire.
EF Core Interceptors: The Complete Guide
Raw COPY and SqlBulkCopy bypass the change tracker, so SaveChanges interceptors never run. Here's how interceptors work and what they cover.
When Should I Use a Bulk-Insert Library?
Reach for a bulk-insert library when you want most of the raw-path speed but still want to work with EF Core entities instead of hand-writing COPY streams. Both of the established options add a BulkInsert onto your DbContext and use the provider’s bulk protocol under the hood - SqlBulkCopy on SQL Server, binary COPY on PostgreSQL - so they land in the same performance class as the BulkInsert row in the benchmark below, while reading like ordinary EF Core code.
Entity Framework Extensions
Entity Framework Extensions by ZZZ Projects is the mature, fully featured option, and the one I’d standardize on when bulk work is a real part of the app rather than a one-off. It adds BulkInsert, BulkUpdate, BulkDelete, and BulkMerge (upsert) - plus BulkSynchronize and BulkSaveChanges - directly onto your context, with the widest database coverage of anything here: SQL Server, PostgreSQL, MySQL, Oracle, and SQLite. Its published benchmarks put it at up to 14x faster with up to 94% less save time on the workloads this article measures.
using Z.EntityFramework.Extensions;
var products = GenerateProducts(50_000);
// bulk insertawait context.BulkInsertAsync(products);
// bulk upsert (insert or update) in a single call, keyed on the primary keyawait context.BulkMergeAsync(products);It is a paid library (per-developer pricing) with commercial support, and the trial refreshes with each monthly release, so you can evaluate it against your real data before committing. If you need first-class upsert with BulkMerge, multi-database coverage, or support you can actually call, this is the safe default.
EFCore.BulkExtensions
EFCore.BulkExtensions is the community option, free under a revenue threshold and a solid fit for smaller projects that only need basic BulkInsert:
using EFCore.BulkExtensions;
var products = GenerateProducts(50_000);await context.BulkInsertAsync(products, cancellationToken: ct);A few things to get right:
- License.
EFCore.BulkExtensionsis no longer plain MIT. It’s a dual-license model: free for individuals and organizations under about $1M USD in annual gross revenue, plus non-profits and open source; above that threshold it needs a paid commercial subscription. A separateEFCore.BulkExtensions.MITpackage stays on the old MIT terms if you need it. - Generated IDs cost extra. By default
BulkInsertdoes not populate your entities’ database-generated IDs. SetSetOutputIdentity = trueto get them back, and know it isn’t free - it routes through a temp table and anOUTPUT/MERGEpath to correlate the keys. - Config knobs.
BatchSize,PreserveInsertOrder, andPropertiesToIncludelet you tune behavior per call.
My rule: if bulk operations are central to the app, or you need BulkMerge, broad database support, or commercial backing, standardize on Entity Framework Extensions. If you’re a small team under the revenue threshold and your need is a plain BulkInsert, EFCore.BulkExtensions covers it for free. Both beat hand-rolling COPY on maintainability while landing in the same speed class.
How Do I Bulk Upsert (Insert or Update) in EF Core?
There is no native bulk upsert in EF Core 10, so you have three practical options. First, a bulk library: BulkInsertOrUpdate in EFCore.BulkExtensions or BulkMerge in Entity Framework Extensions both do insert-or-update keyed on the primary key. Second, raw SQL that leans on the database: INSERT ... ON CONFLICT ... DO UPDATE on PostgreSQL, or MERGE on SQL Server. Third, the free FlexLabs.EntityFrameworkCore.Upsert package, which gives you a fluent On(...).WhenMatched(...) API that translates to ON CONFLICT/MERGE.
For pure inserts where you don’t need EF at all, plain ADO.NET is also on the table. Dapper’s multi-row insert is faster than tracked EF Core but is still parameter-bound, so it hits the same 2,100 / 65,535 ceilings and won’t match COPY. SQL Server table-valued parameters are a flexible middle ground when you want set-based logic server-side. The honest rule: if you’re doing a one-shot import of tens of thousands of rows and you don’t need the entity graph back, EF Core is the wrong tool for that path - go straight to the bulk protocol.
.NET Interview Questions
300+ real .NET interview questions with answers, red flags, and follow-ups - C#, EF Core, ASP.NET Core, system design
Insert Benchmarks: How Much Faster Is Each Approach?
I benchmarked all six strategies with BenchmarkDotNet on .NET 10 and EF Core 10, against PostgreSQL 17 in Docker. The entity is a plain 8-column Product. Each method inserts the full set into a freshly truncated table. Absolute times depend on your hardware, schema width, indexes, and network latency - the relative ordering is the durable result.
| Approach | 1,000 rows | 10,000 rows | Allocated (10K) |
|---|---|---|---|
Add + SaveChanges per row | 975 ms | ~31,500 ms | ~24.7 GB |
AddRange + one SaveChanges | 73 ms | 685 ms | 102 MB |
AddRange, AutoDetectChanges off | 62 ms | 658 ms | 97 MB |
Chunked AddRange + ChangeTracker.Clear() | 70 ms | 670 ms | 97 MB |
EFCore.BulkExtensions BulkInsert | 54 ms | 150 ms | 4 MB |
Raw Npgsql binary COPY | 50 ms | 120 ms | 0.5 MB |
Measured with BenchmarkDotNet v0.15.4 on .NET 10, EF Core 10, and Npgsql 10 against PostgreSQL 17 in Docker (local, Windows), five iterations per method with the table truncated between runs. At 1,000 rows the fast methods sit near the measurement floor - connection setup and round-trip latency dominate - so read the 10,000-row column for the real separation. The “Allocated” figure is managed bytes allocated per run: the per-row loop churns roughly 24.7 GB of garbage inserting 10,000 rows because it builds and tears down the save machinery 10,000 times. Your absolute numbers will differ by hardware and schema; the ordering won’t.
A few things stand out. Per-row SaveChanges is catastrophic and scales linearly with row count - it is the one approach to never ship. AddRange + a single SaveChanges is dramatically better and is genuinely fine for a few thousand rows. From there, EFCore.BulkExtensions and raw COPY pull away, and the gap widens with row count. Just as important as time is memory: the native tracked paths allocate far more because every entity is tracked with a change-detection snapshot, while BulkInsert and raw COPY stream rows and stay nearly flat.
Structured Logging with Serilog in ASP.NET Core
Log rows inserted and elapsed time on every import so you can catch a regression before users do. Structured logging makes those metrics queryable.
My Take: Which One Should You Actually Use?
Start native and only climb the ladder when a real number tells you to. Most Web APIs insert batches of tens to a few thousand rows, and AddRange + SaveChanges handles that with change tracking, generated IDs, and interceptors intact. Reaching for a bulk library on day one is premature optimization that adds a dependency and a licensing question you didn’t need yet.
Here’s the decision I actually make:
| Scenario | Use this | Why |
|---|---|---|
| Up to ~2,000 rows, normal request | AddRange + one SaveChanges | Batching handles it. You keep IDs, interceptors, audit trails. |
| 2K-50K rows, need IDs/interceptors | Chunked AddRange + ChangeTracker.Clear() | Stays native, keeps memory flat, keeps EF behavior. |
| 10K+ rows, entity-based, speed matters | A bulk library: Entity Framework Extensions or EFCore.BulkExtensions (BulkInsert) | Provider bulk protocol with EF ergonomics. |
| Massive one-shot import / ETL | Raw COPY (Postgres) or SqlBulkCopy (SQL Server) | Fastest possible. No tracking, no parameter ceiling. |
| Insert-or-update at scale | BulkMerge (Entity Framework Extensions) / BulkInsertOrUpdate / ON CONFLICT | No native bulk upsert exists. |
| Wide table hitting the parameter cap | Raw COPY / SqlBulkCopy | Streaming sidesteps the 2,100 / 65,535 limit. |
The rule of thumb: the more the job looks like “import a firehose of data I don’t need back,” the further right on this table you should be. The more it looks like “save some entities the app will keep using,” the further left.
How Do I Get the Generated IDs Back After a Bulk Insert?
This is the tradeoff nobody mentions until it bites. The fastest paths give up the least useful thing and the most useful thing at once. Native SaveChanges populates each entity’s Id automatically after insert, because it reads back the generated keys through RETURNING/OUTPUT. Raw COPY and SqlBulkCopy do not - they stream rows in and never report the identities. EFCore.BulkExtensions sits in between: it can return IDs, but only if you opt in with SetOutputIdentity = true, and that flag has a real cost because correlating the keys requires an extra temp-table round trip. So decide before you optimize: if downstream code needs the new IDs, either stay on a native path or pay for SetOutputIdentity. If you’re loading rows you’ll only ever query later, skip identity retrieval and take the speed.
Bulk Operations in EF Core 10
Inserts are half the story. This companion covers bulk UPDATE and DELETE with ExecuteUpdate and ExecuteDelete, plus a full benchmark of all three.
Key Takeaways
- EF Core 10 has no native bulk insert. There’s no
ExecuteInsert; inserts always run throughAdd/AddRange+SaveChangesand the change tracker. - The number-one mistake is
SaveChangesinside the loop - one round trip per row. Move it out and let EF Core batch.AddRangevsAdd-in-a-loop barely matters in modern EF Core. AddRange+ oneSaveChangesis the right default up to a few thousand rows. For tens of thousands, chunk and callChangeTracker.Clear()to keep memory flat.- The fastest paths are raw provider bulk copy - Npgsql binary
COPYandSqlBulkCopy- which skip parameter binding and tracking. A bulk library like Entity Framework Extensions or EFCore.BulkExtensions gives you most of that speed with EF ergonomics. - Watch the parameter ceiling (2,100 SQL Server, 65,535 PostgreSQL) and the fact that raw paths don’t return generated IDs or fire interceptors.
Troubleshooting
”The incoming request has too many parameters. The server supports a maximum of 2100 parameters”
Your batched insert exceeded SQL Server’s 2,100-parameter limit (rows × columns). Lower MaxBatchSize, insert fewer rows per batch, or switch to SqlBulkCopy, which doesn’t bind parameters.
PostgreSQL COPY inserts nothing and no error is thrown
You disposed the NpgsqlBinaryImporter without calling Complete()/CompleteAsync(). Npgsql rolls the import back on dispose unless you explicitly complete it. Always call it as the last step.
Bulk insert times out on large datasets
The default 30-second CommandTimeout is too low for big imports. Raise it: context.Database.SetCommandTimeout(120), or set BulkCopyTimeout on SqlBulkCopy. Also consider chunking so no single command runs that long.
Memory spikes or OutOfMemoryException during AddRange
Every added entity stays tracked. Insert in chunks and call context.ChangeTracker.Clear() between them, or move to BulkInsert/COPY, which don’t track entities.
BulkInsert leaves my entity IDs at 0
EFCore.BulkExtensions doesn’t fetch generated keys by default. Pass a BulkConfig with SetOutputIdentity = true if you need the IDs back, or use a native SaveChanges path.
FAQ
Does EF Core have a built-in bulk insert?
No. EF Core 10 has no bulk insert API. It provides ExecuteUpdate and ExecuteDelete for set-based updates and deletes, but the documentation states that insertion must be done via DbSet.Add and SaveChanges. For high-throughput inserts you use a third-party library like EFCore.BulkExtensions or a raw provider path such as Npgsql binary COPY or SqlBulkCopy.
What is the fastest way to bulk insert in EF Core?
The fastest way is the database's native bulk-copy protocol: binary COPY on PostgreSQL (via Npgsql BeginBinaryImport) or SqlBulkCopy on SQL Server. Both stream rows without binding parameters or tracking entities. In my benchmark, raw COPY was about 5x faster than AddRange plus SaveChanges at 10,000 rows and the gap widens as the row count grows. For a few thousand rows, AddRange with a single SaveChanges is fast enough.
Is AddRange faster than a foreach loop in EF Core?
Only slightly. In modern EF Core, Add in a loop performs only local change detection, so it is close to AddRange in speed. AddRange is cleaner and marginally faster, but the big performance mistake is calling SaveChanges inside the loop, which causes one database round trip per row. Move SaveChanges outside the loop so EF Core can batch the inserts.
How many rows does EF Core batch per insert by default?
The default MaxBatchSize is provider-specific. SQL Server defaults to 42 statements per batch because batching benefits degrade beyond about 40 statements. PostgreSQL via Npgsql defaults to 1,000. The commonly repeated claim that PostgreSQL also defaults to 42 is incorrect. You can change it with MaxBatchSize in the provider options, but benchmark before increasing it.
Is EFCore.BulkExtensions free?
It is free for individuals and organizations under roughly $1M USD in annual gross revenue, and for non-profits and open source projects, under a dual-license model. Organizations above that revenue threshold need a paid commercial subscription. A separate EFCore.BulkExtensions.MIT package remains under the original MIT license terms.
How do I get the generated IDs back after a bulk insert?
Native SaveChanges populates entity IDs automatically because it reads back generated keys via RETURNING or OUTPUT. Raw COPY and SqlBulkCopy do not return IDs. EFCore.BulkExtensions can return them if you set SetOutputIdentity to true in the BulkConfig, which adds a temp-table round trip. If you need the IDs downstream, stay on a native path or opt into SetOutputIdentity.
Why does my bulk insert fail with too many parameters?
Batched EF Core inserts use roughly rows times columns parameters per command, and providers cap this: 2,100 on SQL Server and 65,535 on PostgreSQL. Wide tables or large batches hit the limit and throw. Lower MaxBatchSize, reduce rows per batch, or switch to a streaming path like SqlBulkCopy or binary COPY, which do not bind parameters.
How do I do a bulk upsert (insert or update) in EF Core?
EF Core 10 has no native bulk upsert. Use BulkInsertOrUpdate from EFCore.BulkExtensions or BulkMerge from Entity Framework Extensions, write raw SQL with INSERT ON CONFLICT DO UPDATE on PostgreSQL or MERGE on SQL Server, or use the free FlexLabs.EntityFrameworkCore.Upsert package, which translates a fluent API to ON CONFLICT or MERGE.
Summary
EF Core gives you no bulk insert of its own, and that’s the whole mental model: EF’s job is the tracked entity graph, and a large import is a different job. For everyday inserts, AddRange plus one SaveChanges is the right call and keeps every EF Core feature you rely on. When the volume grows, chunk with ChangeTracker.Clear(), then move to a bulk library like Entity Framework Extensions or EFCore.BulkExtensions, and finally to raw COPY or SqlBulkCopy when you need the absolute floor on time and memory. Match the tool to the job, watch the parameter ceiling, and remember that the fastest paths trade away generated IDs and interceptors for their speed.
Clone the sample repo, run the benchmark against your own schema, and let the numbers pick the approach.
Happy Coding :)
What's your take?
Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.