HomeNikola Knezevic

In this article

Banner

Dapper Transactions in ASP.NET Core

20 Aug 2026
6 min

Special Thanks to Our Sponsors:

Sponsor Logo

EF Core is too slow? Discover how you can easily insert 14x faster (reducing saving time by 94%).

Boost your performance with our method within EF Core: Bulk Insert, update, delete and merge.

Thousands of satisfied customers have trusted our library since 2014.

👉 Learn more

Sponsor Newsletter

When working with databases, a single business operation often needs more than one SQL statement. Creating an order and updating stock should succeed or fail together.

With Entity Framework Core, SaveChanges already wraps those writes in a transaction. With Dapper, each call to ExecuteAsync or QueryAsync runs on its own unless you take control yourself.

That means a failure in the middle can leave your database half updated. You own the connection and you own the transaction boundary.

Now, let's see how transactions work with Dapper in ASP.NET Core. If you are new to Dapper, check out my previous blog post on Getting Started with Dapper. For the EF Core side of the same problem, see Transactions in EF Core.

Why Transactions Matter with Dapper

A transaction groups database operations into one unit of work. Either everything commits, or nothing does.

That is the Atomicity part of ACID. It is what keeps related changes consistent when something fails.

Dapper is a micro ORM. It maps SQL to objects and gets out of the way. It does not manage a change tracker and it does not start an implicit transaction for you.

So when multiple statements must stay consistent, you start a transaction on the connection, run your commands inside it and then Commit or Rollback.

This idea also shows up when you build a custom Unit of Work around Dapper. I cover that pattern in more detail in Unit of Work Pattern in .NET.

Setup

Install Dapper and your database driver. In this example I use PostgreSQL:

shell
dotnet add package Dapper
dotnet add package Npgsql

For cleaner DI and testing, define an ISqlConnectionFactory that opens the connection:

csharp
public interface ISqlConnectionFactory
{
    IDbConnection OpenConnection();
}

public sealed class PostgresConnectionFactory(string connectionString) : ISqlConnectionFactory
{
    public IDbConnection OpenConnection()
    {
        var dbConnection = new NpgsqlConnection(connectionString);
        dbConnection.Open();
        return dbConnection;
    }
}

Register it once at startup:

csharp
builder.Services.AddSingleton<ISqlConnectionFactory>(_ =>
    new PostgresConnectionFactory(
        builder.Configuration.GetConnectionString("Postgres")!));

With the connection factory in place, we can focus on the three ways to run transactions with Dapper.

BeginTransaction

The most common approach is BeginTransaction on IDbConnection. It returns an IDbTransaction that you pass into Dapper methods.

Here is a create order endpoint. We open a connection, start a transaction, reduce stock, insert the order and commit:

csharp
private async Task<IResult> Handler(
    ISqlConnectionFactory connectionFactory,
    Request request)
{
    var order = new Order(OrderStatus.Created, request.ProductId, request.Quantity);

    using var connection = connectionFactory.OpenConnection();
    using var transaction = connection.BeginTransaction();
    try
    {
        await connection.ExecuteAsync(
            """
            UPDATE "Products"
            SET "Stock" = "Stock" - @Quantity
            WHERE "Id" = @ProductId
            """,
            new { request.ProductId, request.Quantity },
            transaction: transaction);

        await connection.ExecuteAsync(
            """
            insert into "Orders" ("Id", "Status", "ProductId", "Quantity", "CreatedAt", "ModifiedAt")
            values (@Id, @Status, @ProductId, @Quantity, @CreatedAt, @ModifiedAt)
            """,
            order,
            transaction: transaction);

        transaction.Commit();

        return Results.Ok(order.Id);
    }
    catch
    {
        transaction.Rollback();
        throw;
    }
}

The important detail is the transaction argument. Dapper only attaches it to the command if you pass it. Some providers still run that command in the open transaction. Others throw. Pass it so the same code works across databases.

On success call Commit. On failure call Rollback and rethrow so callers still see the error.

NOTE: Keep the connection open for the lifetime of the transaction. Closing or disposing it early rolls back any pending work.

Dapper.Transaction

Passing transaction: on every call gets repetitive when you run several commands. The Dapper.Transaction package extends IDbTransaction with the same Dapper APIs.

shell
dotnet add package Dapper.Transaction

Then you call ExecuteAsync directly on the transaction. No extra parameter to forget:

csharp
private async Task<IResult> Handler(
    ISqlConnectionFactory connectionFactory,
    Request request)
{
    var order = new Order(OrderStatus.Created, request.ProductId, request.Quantity);

    using var connection = connectionFactory.OpenConnection();
    using var transaction = connection.BeginTransaction();
    try
    {
        await transaction.ExecuteAsync(
            """
            UPDATE "Products"
            SET "Stock" = "Stock" - @Quantity
            WHERE "Id" = @ProductId
            """,
            new { request.ProductId, request.Quantity });

        await transaction.ExecuteAsync(
            """
            insert into "Orders" ("Id", "Status", "ProductId", "Quantity", "CreatedAt", "ModifiedAt")
            values (@Id, @Status, @ProductId, @Quantity, @CreatedAt, @ModifiedAt)
            """,
            order);

        transaction.Commit();

        return Results.Ok(order.Id);
    }
    catch
    {
        transaction.Rollback();
        throw;
    }
}

Under the hood, the extension still uses the same connection and transaction. The API is just harder to misuse.

I prefer this style when an endpoint runs more than one statement inside the same unit of work.

TransactionScope

Another option is TransactionScope from System.Transactions. It creates an ambient transaction. Connections opened inside the scope can enlist automatically.

You do not pass an IDbTransaction into Dapper. You call Complete when everything succeeded:

csharp
private async Task<IResult> Handler(
    ISqlConnectionFactory connectionFactory,
    Request request)
{
    var order = new Order(OrderStatus.Created, request.ProductId, request.Quantity);

    using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
    using var connection = connectionFactory.OpenConnection();

    await connection.ExecuteAsync(
        """
        UPDATE "Products"
        SET "Stock" = "Stock" - @Quantity
        WHERE "Id" = @ProductId
        """,
        new { request.ProductId, request.Quantity });

    await connection.ExecuteAsync(
        """
        insert into "Orders" ("Id", "Status", "ProductId", "Quantity", "CreatedAt", "ModifiedAt")
        values (@Id, @Status, @ProductId, @Quantity, @CreatedAt, @ModifiedAt)
        """,
        order);

    scope.Complete();

    return Results.Ok(order.Id);
}

Always pass TransactionScopeAsyncFlowOption.Enabled when you use await. Without it, the ambient transaction may not flow across async continuations.

If you never call Complete, disposing the scope rolls everything back. That is convenient, but the behavior can escalate to a distributed transaction depending on providers and connection usage.

For most APIs, I stick with an explicit IDbTransaction. It is clearer, easier to reason about and avoids ambient transaction surprises. Use TransactionScope when you need ambient enlistment across multiple resources.

Choosing an Approach

A quick guide for everyday work:

  • BeginTransaction + transaction parameter - Explicit and built in. Pass the transaction on every Dapper call
  • Dapper.Transaction - Same model, cleaner API when you run several commands
  • TransactionScope - Ambient transactions. Useful across multiple connections or resources, with more moving parts

Whichever you pick, keep related writes on one connection and one transaction boundary. Commit once when the business operation is done.

Conclusion

Dapper does not hide transactions behind SaveChanges. You open the connection, start the transaction and decide when to commit.

Use BeginTransaction for explicit control, Dapper.Transaction to keep that control without repeating parameters and TransactionScope when ambient enlistment is what you need.

Getting this right early prevents half completed writes and hard to debug data issues.

If you want to check out examples I created, you can find the source code here:

Source Code

I hope you enjoyed it, subscribe and get a notification when a new blog is up!

Subscribe

Stay tuned for valuable insights every Thursday morning.