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
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
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
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
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:
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:
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:
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:
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:
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.