<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daniel Davis</title>
    <description>The latest articles on DEV Community by Daniel Davis (@danieldavisdev).</description>
    <link>https://dev.to/danieldavisdev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4235707%2Fcb95d7d5-509e-489f-9052-6d59af781711.png</url>
      <title>DEV Community: Daniel Davis</title>
      <link>https://dev.to/danieldavisdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danieldavisdev"/>
    <language>en</language>
    <item>
      <title>SaveChanges shouldn't take four seconds to insert 100,000 rows</title>
      <dc:creator>Daniel Davis</dc:creator>
      <pubDate>Mon, 09 Nov 2026 20:51:16 +0000</pubDate>
      <link>https://dev.to/danieldavisdev/savechanges-shouldnt-take-four-seconds-to-insert-100000-rows-3i1i</link>
      <guid>https://dev.to/danieldavisdev/savechanges-shouldnt-take-four-seconds-to-insert-100000-rows-3i1i</guid>
      <description>&lt;p&gt;If you have ever watched an import job stall after &lt;code&gt;AddRange&lt;/code&gt; and &lt;code&gt;SaveChanges&lt;/code&gt;, you already know the feeling. EF Core is doing honest work: it tracks every entity, generates commands, and talks to SQL Server more than you wanted. That is the right tradeoff when you are saving a cart or a user profile. It is a painful one when you are writing thousands or hundreds of thousands of rows.&lt;/p&gt;

&lt;p&gt;I also know the other path by heart: &lt;code&gt;SqlBulkCopy&lt;/code&gt; into a temp table, a &lt;code&gt;MERGE&lt;/code&gt; with &lt;code&gt;HOLDLOCK&lt;/code&gt; so two imports do not race, a transaction you actually commit, and a decision about identity values. It works. It is also easy to get slightly wrong.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;SqlBulkPro&lt;/strong&gt;: a small .NET 8/9 library that uses &lt;code&gt;SqlBulkCopy&lt;/code&gt; and &lt;code&gt;MERGE&lt;/code&gt; under the hood and gives you one method for the usual cases. It works with EF Core and with Dapper / ADO.NET. This post is the problem, the API, real numbers from one benchmark machine, and when you should &lt;em&gt;not&lt;/em&gt; use it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;SaveChanges&lt;/code&gt; slows down at volume
&lt;/h3&gt;

&lt;p&gt;EF Core is a change tracker first and a SQL generator second. For a handful of rows that is a gift. At a few thousand rows, every entity is tracked, and setup plus round-trip overhead starts to dominate. That is not a knock on EF Core. I still use it for the rest of the app. I just stopped asking it to be a bulk loader.&lt;/p&gt;

&lt;p&gt;There is another long-open gap: EF Core has no built-in upsert. If you have followed &lt;a href="https://github.com/dotnet/efcore/issues/4526" rel="noopener noreferrer"&gt;dotnet/efcore#4526&lt;/a&gt; since 2016, you have probably written the same "try update, else insert" loop more than once.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why hand-rolled &lt;code&gt;SqlBulkCopy&lt;/code&gt; + &lt;code&gt;MERGE&lt;/code&gt; is fiddly
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;SqlBulkCopy&lt;/code&gt; is fast because it is a streaming bulk path into SQL Server. Insert-only is the easy part. The moment you need "insert if new, update if the key already exists," you are creating a temp table, bulk-copying into it, &lt;code&gt;MERGE&lt;/code&gt;ing with &lt;code&gt;HOLDLOCK&lt;/code&gt; if two writers can collide, and keeping the whole thing in one transaction so a failed merge does not leave a half-written table.&lt;/p&gt;

&lt;p&gt;It is easy to skip the lock hint or forget to enlist &lt;code&gt;SqlBulkCopy&lt;/code&gt; in the transaction. Those bugs are rare until they are not.&lt;/p&gt;

&lt;h3&gt;
  
  
  What SqlBulkPro does
&lt;/h3&gt;

&lt;p&gt;SqlBulkPro wraps that pattern behind a boring API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;SqlBulkPro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dbContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BulkInsertAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dbContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BulkUpsertAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dbContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BulkUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dbContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BulkDeleteAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood it still uses &lt;code&gt;SqlBulkCopy&lt;/code&gt; and &lt;code&gt;MERGE&lt;/code&gt;. It supports composite keys, identity values written back onto your entities, EF Core value converters, &lt;code&gt;HOLDLOCK&lt;/code&gt; when you want to prevent races, batch size and timeout settings, column include/exclude lists, and ambient transactions. The hot path uses compiled expressions, not per-row reflection.&lt;/p&gt;

&lt;p&gt;Install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package SqlBulkPro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only need insert, the free MIT sibling &lt;code&gt;SqlBulkPro.Lite&lt;/code&gt; has the same insert API and no license key.&lt;/p&gt;

&lt;p&gt;It targets .NET 8 and .NET 9, talks to SQL Server 2016 or later, and the EF helpers expect EF Core 8 or later.&lt;/p&gt;

&lt;h3&gt;
  
  
  A few friendly examples
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;EF Core insert.&lt;/strong&gt; This is the one-liner for an import job that would otherwise call &lt;code&gt;AddRange&lt;/code&gt; + &lt;code&gt;SaveChanges&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;SqlBulkPro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;LoadProductsFromFile&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// thousands of rows&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dbContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BulkInsertAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Upsert with your own key columns.&lt;/strong&gt; Useful when the natural key is not the identity column — a product code plus a region, a source system id, and so on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;SqlBulkPro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;BulkOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;KeyColumns&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"ProductCode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Region"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;ColumnsToIgnore&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"LastModified"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;InsertOnlyColumns&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;"CreatedAt"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;BatchSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;TimeoutSeconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;UseHoldLock&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ReturnIdentities&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dbContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BulkUpsertAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;KeyColumns&lt;/code&gt; tells &lt;code&gt;MERGE&lt;/code&gt; how to match. &lt;code&gt;ColumnsToIgnore&lt;/code&gt; stays out of the update. &lt;code&gt;InsertOnlyColumns&lt;/code&gt; is written on insert and left alone later (&lt;code&gt;CreatedAt&lt;/code&gt; is the usual case). &lt;code&gt;UseHoldLock&lt;/code&gt; prevents concurrent upserts from racing. &lt;code&gt;ReturnIdentities&lt;/code&gt; writes generated ids back onto the objects you passed in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dapper / ADO.NET.&lt;/strong&gt; If you are not using a &lt;code&gt;DbContext&lt;/code&gt; for the write path, the same operations hang off &lt;code&gt;SqlConnection&lt;/code&gt;. You can point at a table and name the key with a short fluent config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;SqlBulkPro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;Microsoft.Data.SqlClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;SqlConnection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BulkInsertAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BulkUpsertAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Products"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"dbo"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HasKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Inside a transaction.&lt;/strong&gt; The library respects ambient transactions, so a bulk write can sit next to other work and commit or roll back together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;SqlBulkPro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Transactions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TransactionScope&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TransactionScopeAsyncFlowOption&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Enabled&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dbContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BulkInsertAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newProducts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;dbContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BulkUpsertAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existingProducts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Complete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That way a bulk write can succeed or fail together with the rest of the unit of work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Numbers from our benchmark machine
&lt;/h3&gt;

&lt;p&gt;These are from one BenchmarkDotNet run (1 warmup, 3 measured iterations) on our benchmark machine: a 4-core Xeon, .NET 9, and SQL Server 2022 in Docker on that same box. Insert compares &lt;code&gt;AddRange&lt;/code&gt; + &lt;code&gt;SaveChanges&lt;/code&gt; with &lt;code&gt;BulkInsertAsync&lt;/code&gt;. "Update vs upsert" compares &lt;code&gt;UpdateRange&lt;/code&gt; + &lt;code&gt;SaveChanges&lt;/code&gt; with &lt;code&gt;BulkUpsertAsync&lt;/code&gt; after the rows already exist. Your hardware and indexes will differ; this is what that machine measured.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Rows&lt;/th&gt;
&lt;th&gt;EF Core mean&lt;/th&gt;
&lt;th&gt;SqlBulkPro mean&lt;/th&gt;
&lt;th&gt;EF / SqlBulkPro&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Insert&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;96.96 ms&lt;/td&gt;
&lt;td&gt;17.84 ms&lt;/td&gt;
&lt;td&gt;5.4×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;517.66 ms&lt;/td&gt;
&lt;td&gt;54.06 ms&lt;/td&gt;
&lt;td&gt;9.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insert&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;3,768.89 ms&lt;/td&gt;
&lt;td&gt;554.19 ms&lt;/td&gt;
&lt;td&gt;6.8×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update vs upsert&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;106.31 ms&lt;/td&gt;
&lt;td&gt;28.11 ms&lt;/td&gt;
&lt;td&gt;3.8×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update vs upsert&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;614.58 ms&lt;/td&gt;
&lt;td&gt;109.36 ms&lt;/td&gt;
&lt;td&gt;5.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update vs upsert&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;5,226.42 ms&lt;/td&gt;
&lt;td&gt;856.17 ms&lt;/td&gt;
&lt;td&gt;6.1×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On that run, 100,000 inserts went from about 3.8 seconds to about 0.55 seconds. Across these cases the mean time was roughly 4× to 10× lower than &lt;code&gt;SaveChanges&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  When you should not use this
&lt;/h3&gt;

&lt;p&gt;If you are saving 10 or 50 rows, stay with &lt;code&gt;SaveChanges&lt;/code&gt;. The bulk path has setup cost, and EF Core's usual pipeline is simpler, more visible in logs, and plenty fast at that size.&lt;/p&gt;

&lt;p&gt;If you need a graph of parents, children, and owned types saved with the full change tracker, this is the wrong tool. SqlBulkPro is for flat sets of rows headed at a table.&lt;/p&gt;

&lt;p&gt;If you only need insert and you want zero licensing, start with Lite instead of Pro. And if your database is not SQL Server 2016 or later, this package will not help you. It is built around &lt;code&gt;SqlBulkCopy&lt;/code&gt; and &lt;code&gt;MERGE&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it without buying anything
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;SqlBulkPro.Lite&lt;/strong&gt; is MIT licensed and insert-only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package SqlBulkPro.Lite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;SqlBulkPro&lt;/strong&gt; adds upsert, update, delete, and sync. You can try it without a key: each operation is capped at 1,000 rows, and a 14-day trial check runs outside Development. In Development (or with a debugger attached) that 14-day check is skipped; the row cap still applies.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package SqlBulkPro
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you have a key, register it at startup or set an environment variable. A missing variable leaves the library in trial mode. It does not throw.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;SqlBulkPro&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;SqlBulkProLicense&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Configuration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"SqlBulkPro:LicenseKey"&lt;/span&gt;&lt;span class="p"&gt;]!);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SQLBULKPRO_LICENSE_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"paste-the-key-here"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.nuget.org/packages/SqlBulkPro" rel="noopener noreferrer"&gt;SqlBulkPro on NuGet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.nuget.org/packages/SqlBulkPro.Lite" rel="noopener noreferrer"&gt;SqlBulkPro.Lite on NuGet&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Pro fits, licenses are &lt;a href="https://buy.stripe.com/cNi14pecGgBr0xh23ZdMI00" rel="noopener noreferrer"&gt;$149 per developer per year&lt;/a&gt; or &lt;a href="https://buy.stripe.com/14AfZj3y20Cta7RaAvdMI01" rel="noopener noreferrer"&gt;$599 per year for a team of up to 10 developers&lt;/a&gt;. The key is emailed after checkout. Questions and feedback are welcome at &lt;a href="mailto:dbdavis1990apple@gmail.com"&gt;dbdavis1990apple@gmail.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I built this because I was tired of choosing between a slow &lt;code&gt;SaveChanges&lt;/code&gt; and a MERGE script I did not want to maintain again. If it saves you that choice on one import job, it did what I hoped.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>sqlserver</category>
      <category>database</category>
    </item>
  </channel>
</rss>
