Dapper is a micro-ORM that extends IDbConnection with methods to execute SQL and map results to objects. It does not generate SQL, track changes, or manage migrations — you write the queries, Dapper maps the results. This makes it fast, predictable, and ideal for read-heavy workloads or complex queries that ORMs struggle with.

Setup

dotnet add package Dapper
dotnet add package Microsoft.Data.SqlClient

Dapper works with any IDbConnection — SQL Server, PostgreSQL (Npgsql), MySQL, SQLite.

Basic Queries

Query a List

Example.cs
public class OrderRepository
{
    private readonly IDbConnection _db;

    public OrderRepository(IDbConnection db) => _db = db;

    public async Task<IEnumerable<Order>> GetByCustomerAsync(int customerId)
    {
        const string sql = """
            SELECT Id, CustomerId, Total, CreatedAt
            FROM Orders
            WHERE CustomerId = @CustomerId
            ORDER BY CreatedAt DESC
            """;

        return await _db.QueryAsync<Order>(sql, new { CustomerId = customerId });
    }
}

Dapper maps columns to properties by name (case-insensitive). The @CustomerId parameter is automatically parameterised — no SQL injection risk.

Query a Single Record

Example.cs
public async Task<Order?> GetByIdAsync(int id)
{
    const string sql = "SELECT * FROM Orders WHERE Id = @Id";

    return await _db.QuerySingleOrDefaultAsync<Order>(sql, new { Id = id });
}

Use QuerySingleOrDefaultAsync when you expect zero or one result. It throws if there are multiple matches.

Executing Commands

Insert

Example.cs
public async Task<int> CreateAsync(Order order)
{
    const string sql = """
        INSERT INTO Orders (CustomerId, Total, CreatedAt)
        VALUES (@CustomerId, @Total, @CreatedAt);
        SELECT CAST(SCOPE_IDENTITY() AS INT);
        """;

    return await _db.ExecuteScalarAsync<int>(sql, order);
}

Dapper maps properties from the order object to the SQL parameters automatically.

Update and Delete

Example.cs
public async Task UpdateTotalAsync(int orderId, decimal newTotal)
{
    const string sql = "UPDATE Orders SET Total = @Total WHERE Id = @Id";

    await _db.ExecuteAsync(sql, new { Id = orderId, Total = newTotal });
}

public async Task DeleteAsync(int orderId)
{
    await _db.ExecuteAsync("DELETE FROM Orders WHERE Id = @Id", new { Id = orderId });
}

Multi-Mapping

Map a single query to multiple objects using splitOn:

Example.cs
public async Task<IEnumerable<Order>> GetOrdersWithCustomerAsync()
{
    const string sql = """
        SELECT o.Id, o.Total, o.CreatedAt,
               c.Id, c.Name, c.Email
        FROM Orders o
        INNER JOIN Customers c ON o.CustomerId = c.Id
        """;

    return await _db.QueryAsync<Order, Customer, Order>(
        sql,
        (order, customer) =>
        {
            order.Customer = customer;
            return order;
        },
        splitOn: "Id");
}

The splitOn parameter tells Dapper where to split the columns between objects. It defaults to Id, but you need to specify it when the split column has a different name.

Multiple Result Sets

Execute a query that returns multiple result sets:

Example.cs
public async Task<(Order Order, IEnumerable<OrderItem> Items)> GetOrderDetailAsync(int id)
{
    const string sql = """
        SELECT * FROM Orders WHERE Id = @Id;
        SELECT * FROM OrderItems WHERE OrderId = @Id;
        """;

    using var multi = await _db.QueryMultipleAsync(sql, new { Id = id });

    var order = await multi.ReadSingleAsync<Order>();
    var items = await multi.ReadAsync<OrderItem>();

    return (order, items);
}

Bulk Operations

Pass a collection to execute a command for each item:

Example.cs
public async Task InsertItemsAsync(IEnumerable<OrderItem> items)
{
    const string sql = """
        INSERT INTO OrderItems (OrderId, ProductId, Quantity, Price)
        VALUES (@OrderId, @ProductId, @Quantity, @Price)
        """;

    await _db.ExecuteAsync(sql, items);
}

Dapper iterates over the collection and executes the command for each element.

Transactions

Wrap multiple operations in a transaction:

Example.cs
public async Task PlaceOrderAsync(Order order, List<OrderItem> items)
{
    _db.Open();
    using var transaction = _db.BeginTransaction();

    try
    {
        var orderId = await _db.ExecuteScalarAsync<int>(
            """
            INSERT INTO Orders (CustomerId, Total, CreatedAt)
            VALUES (@CustomerId, @Total, @CreatedAt);
            SELECT CAST(SCOPE_IDENTITY() AS INT);
            """,
            order,
            transaction);

        foreach (var item in items)
            item.OrderId = orderId;

        await _db.ExecuteAsync(
            """
            INSERT INTO OrderItems (OrderId, ProductId, Quantity, Price)
            VALUES (@OrderId, @ProductId, @Quantity, @Price)
            """,
            items,
            transaction);

        transaction.Commit();
    }
    catch
    {
        transaction.Rollback();
        throw;
    }
}

DI Registration

Register the connection as scoped so each request gets its own connection:

Program.cs
builder.Services.AddScoped<IDbConnection>(_ =>
    new SqlConnection(builder.Configuration.GetConnectionString("Default")));

When to Use Dapper

Dapper shines for read-heavy applications, reporting queries, complex joins, and scenarios where you want full SQL control. It pairs well with EF Core — use EF Core for simple CRUD and change tracking, and Dapper for complex read queries where EF Core's generated SQL is suboptimal. There is no reason you cannot use both in the same project.