How To Fix Slow Write Queries in Dapper With the Dapper Plus Library
Learn why Dapper inserts and updates get slow for big batches, and how Dapper Plus BulkInsert, BulkUpdate, BulkMerge make writes up to 75x faster.
How To Fix Slow Write Queries in Dapper With the Dapper Plus Library
Imagine you have to carry 1,000 bricks from a truck to a wall. You could pick up one brick, walk over, drop it, and walk back a thousand times. That would take all day and your legs would hurt. Or you could load 100 bricks onto a wheelbarrow and make only 10 trips. Same bricks, but far fewer trips.
Saving rows to a database works the same way. Plain Dapper, by default, carries your rows one at a time. Dapper Plus loads them onto a wheelbarrow and makes a handful of trips. That one idea is the whole article. Let me show you why it matters and exactly how to do it.
What this article covers
We will look at why Dapper write queries get slow, what a "round-trip" is, how Dapper Plus fixes it, and the exact code you write. By the end you will be able to turn a slow loop of inserts into one fast bulk call.
The journey of this article
Steps
Problem
Inserts in a loop feel slow
Why slow
One trip per row
Dapper Plus
Batches rows together
Code
BulkInsert and friends
Results
Up to 75x faster writes
A quick word on Dapper
Dapper is a tiny, fast tool that helps your C# code talk to a database. You write the SQL yourself, and Dapper maps the results into your C# objects. It is loved because it is simple and quick for reading data.
But Dapper does not give you a built-in fast way to write a lot of rows. There is no magic "save this whole list quickly" button in the box. So most people write a loop, and that is where the trouble starts.
The slow pattern almost everyone writes first
Here is the code nearly every Dapper user writes on day one. We have a list of products and we want to save them.
public async Task InsertProductsAsync(
IDbConnection connection,
List<Product> products)
{
const string sql = """
INSERT INTO Products (Name, Price, CreatedAt)
VALUES (@Name, @Price, @CreatedAt);
""";
// This runs ONE insert command per product.
foreach (var product in products)
{
await connection.ExecuteAsync(sql, product);
}
}This code is correct. It works. For 5 or 10 rows you will never notice a problem. But for 1,000 or 50,000 rows it crawls. Why? Because of round-trips.
What is a "round-trip"?
A round-trip is one full conversation with the database: your app asks, the network carries the message, the database does the work, and the answer comes back. Each round-trip costs a little time, even when the actual insert is tiny.
If you loop over 1,000 products, you make 1,000 separate round-trips. The database is fast, but the back-and-forth over the network adds up. It is like sending 1,000 separate text messages instead of one message with a list.
Even with a single open connection, each ExecuteAsync is its own command. The waiting between commands is the silent time-eater.
The fix: send rows in batches
Dapper Plus is a small add-on library (the NuGet package is named Z.Dapper.Plus). It adds new methods like BulkInsert, BulkUpdate, BulkDelete, BulkMerge, and BulkSynchronize straight onto your IDbConnection. Instead of one command per row, it groups many rows into a few commands.
The same wheelbarrow idea from the start. Fewer trips, far less waiting.
Installing Dapper Plus
You add it like any other package.
// In the terminal, from your project folder:
// dotnet add package Z.Dapper.Plus
// Then in your file:
using Z.Dapper.Plus;Note: Dapper Plus is a commercial library from ZZZ Projects. It has a free trial that resets at the start of each month, which is great for learning and testing. For a real production app you usually need a paid license. Always check the current terms on the official site before you ship.
Telling Dapper Plus about your tables (mapping)
Before you call BulkInsert, Dapper Plus needs to know which table to use and which property is the identity (the auto-generated Id). You do this once, when your app starts, using DapperPlusManager.
DapperPlusManager.Entity<Product>()
.Table("Products") // the table name in the database
.Identity(x => x.Id); // the auto-generated primary key
DapperPlusManager.Entity<Order>()
.Table("Orders")
.Identity(x => x.Id)
.BatchSize(200); // how many rows to send per commandThe BatchSize setting controls how big each "wheelbarrow load" is. A common starting value is around 200 to 1000 rows per batch. You can tune it later.
The four steps to fast writes
Steps
Install
Add Z.Dapper.Plus
Map
Set table and identity
Bulk call
connection.BulkInsert(list)
Tune
Adjust BatchSize
Now the fast insert
With mapping in place, the slow loop becomes a single clean line.
public void InsertProductsFast(
IDbConnection connection,
List<Product> products)
{
// One call. Dapper Plus batches all the rows for you.
connection.BulkInsert(products);
}That is it. No loop. No manual SQL string. Dapper Plus builds the batched commands behind the scenes and sends them in as few trips as possible. After it runs, the auto-generated Id values are filled back into your objects too, because we set .Identity(...) in the mapping.
The other bulk methods
BulkInsert is the star, but the same idea powers a small family of methods. Each one saves a whole list at once.
| Method | What it does | Real-life picture |
|---|---|---|
BulkInsert | Adds many new rows | Putting all new books on the shelf |
BulkUpdate | Changes many existing rows | Re-pricing every item in a list |
BulkDelete | Removes many rows | Clearing out old stock |
BulkMerge | Updates existing rows and inserts new ones (an "upsert") | Restocking: update what's there, add what's missing |
BulkSynchronize | Makes the table match your list exactly: update, insert, and delete | Making the shelf a mirror of your fresh delivery |
Here is BulkMerge, the most useful one for data that may or may not already exist. It is an upsert: update the rows that exist, insert the ones that do not.
public void SaveOrUpdateProducts(
IDbConnection connection,
List<Product> products)
{
// Existing products get updated.
// Brand-new products get inserted.
connection.BulkMerge(products);
}And BulkSynchronize goes one step further. It makes the table look exactly like your list. Rows not in your list get deleted. Use this carefully, because deleting is permanent.
How much faster is it really?
The official benchmarks from Dapper Plus show big gains, especially on SQL Server, because that provider has the most optimized path. The numbers below are rough guides from their published tests. Your own results depend on your network, row size, and database.
| Operation | Speed-up vs plain Dapper loop | Time saved |
|---|---|---|
| Insert | up to ~75x faster | up to ~93% less |
| Update | up to ~50x faster | up to ~75% less |
| Delete | up to ~150x faster | up to ~65% less |
| Merge | up to ~50x faster | up to ~98% less |
Even for a small batch of 50 rows, the docs report saving times dropping by around 88%. The bigger your batch, the bigger the win, because you are cutting out more and more round-trips.
Why it is so much faster (the simple version)
Three things make the difference:
- Fewer round-trips. This is the main one. Many rows ride together in one command.
- Less command overhead. Each SQL command has a small setup cost. One big command beats a thousand small ones.
- Smart provider paths. On SQL Server, Dapper Plus can use the fast built-in bulk-copy machinery under the hood.
A safe way to add it to a real project
You do not have to change everything at once. Here is a calm, low-risk plan.
Rolling Dapper Plus into an existing app
Steps
Find loops
Search for insert loops
Pick one
Choose the slowest
Replace
Use BulkInsert
Measure
Compare timings
Repeat
Move to the next
Start by finding the place that saves the most rows, like an import job or a nightly sync. Replace that single loop first. Measure the time before and after with a simple Stopwatch. When you see the win with your own eyes, move on to the next one.
var watch = System.Diagnostics.Stopwatch.StartNew();
connection.BulkInsert(products);
watch.Stop();
Console.WriteLine($"Saved {products.Count} rows in {watch.ElapsedMilliseconds} ms");Things to watch out for
A few honest warnings so you do not get surprised:
- It costs money for production. The free trial is monthly. Budget for a license if this goes live.
- Mapping must be set up once. Forgetting
DapperPlusManager.Entity<T>()is the most common beginner mistake. Do it at startup. BulkSynchronizedeletes rows. Be very sure your list is complete before using it, or you may erase data you meant to keep.- Wrap big jobs in a transaction when you need all-or-nothing safety. Bulk methods work with an
IDbTransactiontoo. - Pick a sensible
BatchSize. Too small and you lose the benefit; too large and you may use lots of memory. Start around 200 to 1000 and test.
Using bulk methods inside a transaction
Sometimes you want all your rows to save together or not at all. For example, if you import 1,000 orders and the network drops at row 700, you may not want half-saved data. A transaction gives you that safety. If anything fails, the database rolls everything back as if it never happened.
using var transaction = connection.BeginTransaction();
try
{
transaction.BulkInsert(orders);
transaction.BulkInsert(orderItems);
transaction.Commit(); // all rows saved together
}
catch
{
transaction.Rollback(); // nothing saved on failure
throw;
}Notice we call BulkInsert on the transaction, not the connection. This keeps both bulk inserts inside the same safe boundary. It is the same fast batching, just wrapped in an all-or-nothing promise.
When you do NOT need Dapper Plus
Be fair to plain Dapper. If you are saving one row, or just a handful, the normal ExecuteAsync is perfect. Dapper Plus shines only when you save many rows at once. Do not add a paid library to insert a single user record. Use the right tool for the size of the job.
Quick recap
- Plain Dapper saves rows one at a time, which means one slow round-trip per row.
- Many round-trips are the real reason big inserts and updates feel slow.
- Dapper Plus batches many rows into a few commands, like a wheelbarrow instead of carrying one brick at a time.
- Install
Z.Dapper.Plus, set up mapping once withDapperPlusManager.Entity<T>(), then callBulkInsert,BulkUpdate,BulkMerge, orBulkSynchronize. - Official benchmarks report inserts up to ~75x faster on SQL Server.
- It is a commercial library with a monthly free trial, so plan for a license in production.
- Use it for big batches; keep plain Dapper for single rows.
References and further reading
- Dapper Plus official site — overview and live examples.
- Bulk Insert documentation — the fastest way to insert multiple rows.
- Bulk Merge documentation — upsert many rows at once.
- Bulk Synchronize documentation — mirror your list into the table.
- Dapper Plus on GitHub (zzzprojects) — source, issues, and release notes.
- Z.Dapper.Plus on NuGet — the package to install.
- How To Fix Slow Write Queries in Dapper With Dapper Plus (Anton Martyniuk) — a well-known community walkthrough.
- Learn Dapper: Bulk Insert — friendly tutorial with more samples.
Related Posts
EF Core Bulk Insert: Boost Performance with Entity Framework Extensions
Learn how EF Core bulk insert with Entity Framework Extensions saves data faster, using simple examples, diagrams, and clear performance comparisons.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
The Real Cost of Returning the Identity Value in EF Core
Why EF Core asking the database for the new Id after every insert costs round trips, and how HiLo, sequences, and Guids cut that cost down.
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.
How I Increased a Production Payment System's Performance by 15x With EF Core Extensions
A true-to-life story of making a slow EF Core payment system 15x faster using bulk extensions, ExecuteUpdate, and ExecuteDelete with simple examples.
Unleash EF Core Performance With Compiled Queries
Learn EF Core compiled queries in .NET 10 with EF.CompileQuery and EF.CompileAsyncQuery. Simple words, real examples, and clear before-and-after numbers.