Skip to main content
SEMastery
Data Accessintermediate

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.

11 min readUpdated December 17, 2025

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

Problem
Why slow
Dapper Plus
Code
Results

Steps

1

Problem

Inserts in a loop feel slow

2

Why slow

One trip per row

3

Dapper Plus

Batches rows together

4

Code

BulkInsert and friends

5

Results

Up to 75x faster writes

We move step by step from the problem to a working fix.

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.

One insert per row means one round-trip per row.

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.

Bulk insert groups many rows into one trip.

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 command

The 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

Install
Map
Bulk call
Tune

Steps

1

Install

Add Z.Dapper.Plus

2

Map

Set table and identity

3

Bulk call

connection.BulkInsert(list)

4

Tune

Adjust BatchSize

A simple checklist you can follow every time.

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.

MethodWhat it doesReal-life picture
BulkInsertAdds many new rowsPutting all new books on the shelf
BulkUpdateChanges many existing rowsRe-pricing every item in a list
BulkDeleteRemoves many rowsClearing out old stock
BulkMergeUpdates existing rows and inserts new ones (an "upsert")Restocking: update what's there, add what's missing
BulkSynchronizeMakes the table match your list exactly: update, insert, and deleteMaking 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 the bulk methods relate to insert, update, and delete.

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.

OperationSpeed-up vs plain Dapper loopTime saved
Insertup to ~75x fasterup to ~93% less
Updateup to ~50x fasterup to ~75% less
Deleteup to ~150x fasterup to ~65% less
Mergeup to ~50x fasterup 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:

  1. Fewer round-trips. This is the main one. Many rows ride together in one command.
  2. Less command overhead. Each SQL command has a small setup cost. One big command beats a thousand small ones.
  3. Smart provider paths. On SQL Server, Dapper Plus can use the fast built-in bulk-copy machinery under the hood.
The state of a save operation, from start to finish.

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

Find loops
Pick one
Replace
Measure
Repeat

Steps

1

Find loops

Search for insert loops

2

Pick one

Choose the slowest

3

Replace

Use BulkInsert

4

Measure

Compare timings

5

Repeat

Move to the next

Small steps keep the change safe and easy to review.

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.
  • BulkSynchronize deletes 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 IDbTransaction too.
  • 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 with DapperPlusManager.Entity<T>(), then call BulkInsert, BulkUpdate, BulkMerge, or BulkSynchronize.
  • 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

Related Posts