Dapper

Query PostgreSQL with Dapper: parameters, row mapping, multi-mapping and cancellation, and know when it is a better fit than EF Core.

Syntaxawait using var conn = await dataSource.OpenConnectionAsync(ct); var rows = await conn.QueryAsync<OrderSummary>( new CommandDefinition("SELECT ... WHERE customer_id = @customerId", new { customerId }, cancellationToken: ct));

Dapper is a set of extension methods on DbConnection: you write the SQL, it turns an object into command parameters and maps each row onto a type. There is no model, no change tracking, no migrations and no SQL generation, which is why teams reach for it.

How mapping works

The first time Dapper sees a combination of SQL text, parameter type and result type it emits IL that reads the columns into members, and caches it. Columns match properties by name, case-insensitively; set DefaultTypeMap.MatchNamesWithUnderscores = true once at start-up so total_amount fills TotalAmount. A type with no parameterless constructor, such as a positional record, needs a constructor whose parameters match the columns by name, in order, with compatible types: a bigint column into an int parameter fails at run time. Classes with init properties are less brittle. The mapper uses Reflection.Emit, so plain Dapper does not work under Native AOT.

Parameters

new { customerId, limit } becomes @customerId and @limit. Values always travel as parameters, so injection only happens when you build the SQL string from input yourself. Two traps: a list used as IN @ids is expanded to (@ids1, @ids2, ...), so each list length is a new statement for the server's plan cache; on PostgreSQL write = ANY(@ids) and Npgsql sends one array. On SQL Server strings go as nvarchar; against a varchar column that forces a conversion that can turn an index seek into a scan, fixed with new DbString { Value = sku, IsAnsi = true, Length = 20 }.

Multi-mapping and multiple results

QueryAsync<Order, Customer, Order>(sql, map, splitOn: "id") cuts each row at the column named in splitOn (default Id) and passes both objects to your lambda. In a one-to-many join the parent repeats once per child, so de-duplicate with a dictionary. QueryMultipleAsync runs several statements in one round trip and reads the result sets in order.

Defaults that bite

  • QueryAsync is buffered: every row is in a list before it returns. Stream large exports with QueryUnbufferedAsync.
  • The QueryAsync(sql, param) overloads take no CancellationToken. Wrap the call in CommandDefinition, or an aborted request leaves the query running on the server.
  • Dapper opens a closed connection for one call and closes it after; open it yourself for several commands.
  • Stored procedures take commandType: CommandType.StoredProcedure; output parameters need DynamicParameters, read with Get<T> after the call.

When Dapper beats EF Core

WorkloadBetter fit
Reports and read models with CTEs, window functions, LATERALDapper
Hot read path whose SQL a DBA tunedDapper
Writes to an aggregate with relationships and concurrency tokensEF Core
Schema changes over timeEF Core migrations (Dapper has none)

Raw speed is no longer the main argument: EF Core with AsNoTracking and a projection is close. Choose Dapper for control of the exact SQL.

Example

Example · csharp
using Dapper;
using Microsoft.AspNetCore.Http.HttpResults;
using Npgsql;

var builder = WebApplication.CreateBuilder(args);

// Maps snake_case columns (total_amount) to PascalCase properties (TotalAmount).
DefaultTypeMap.MatchNamesWithUnderscores = true;

var connectionString = builder.Configuration.GetConnectionString("Shop")
    ?? throw new InvalidOperationException("ConnectionStrings:Shop is not configured.");
builder.Services.AddSingleton(NpgsqlDataSource.Create(connectionString));
builder.Services.AddScoped<OrderQueries>();

var app = builder.Build();

app.MapGet("/customers/{customerId:long}/orders",
    async (long customerId, int? limit, OrderQueries queries, CancellationToken ct) =>
        TypedResults.Ok(await queries.RecentForCustomerAsync(customerId, Math.Clamp(limit ?? 20, 1, 100), ct)));

app.MapGet("/orders/{id:long}",
    async Task<Results<Ok<OrderDetail>, NotFound>> (long id, OrderQueries queries, CancellationToken ct) =>
        await queries.FindAsync(id, ct) is { } order ? TypedResults.Ok(order) : TypedResults.NotFound());

app.MapPost("/orders/{id:long}/cancel",
    async Task<Results<NoContent, Conflict>> (long id, OrderQueries queries, CancellationToken ct) =>
        await queries.CancelAsync(id, ct) ? TypedResults.NoContent() : TypedResults.Conflict());

app.Run();

public sealed class OrderQueries(NpgsqlDataSource dataSource)
{
    public async Task<IReadOnlyList<OrderSummary>> RecentForCustomerAsync(long customerId, int limit, CancellationToken ct)
    {
        const string sql = """
            SELECT id, status, total_amount, placed_at
            FROM orders
            WHERE customer_id = @customerId
            ORDER BY placed_at DESC
            LIMIT @limit
            """;
        await using var connection = await dataSource.OpenConnectionAsync(ct);
        var rows = await connection.QueryAsync<OrderSummary>(
            new CommandDefinition(sql, new { customerId, limit }, cancellationToken: ct));
        return rows.AsList();
    }

    public async Task<OrderDetail?> FindAsync(long id, CancellationToken ct)
    {
        const string sql = """
            SELECT o.id, o.status, o.total_amount, o.placed_at,
                   c.id, c.name, c.email
            FROM orders o
            JOIN customers c ON c.id = o.customer_id
            WHERE o.id = @id
            """;
        await using var connection = await dataSource.OpenConnectionAsync(ct);
        var rows = await connection.QueryAsync<OrderDetail, CustomerRef, OrderDetail>(
            new CommandDefinition(sql, new { id }, cancellationToken: ct),
            (order, customer) => { order.Customer = customer; return order; },
            splitOn: "id");
        return rows.SingleOrDefault();
    }

    public async Task<bool> CancelAsync(long id, CancellationToken ct)
    {
        // The status condition makes the update a no-op for shipped orders, so no read is needed first.
        const string sql = "UPDATE orders SET status = 'Cancelled' WHERE id = @id AND status = 'Pending'";
        await using var connection = await dataSource.OpenConnectionAsync(ct);
        return await connection.ExecuteAsync(new CommandDefinition(sql, new { id }, cancellationToken: ct)) == 1;
    }
}

public sealed class OrderSummary
{
    public long Id { get; init; }
    public string Status { get; init; } = "";
    public decimal TotalAmount { get; init; }
    public DateTime PlacedAt { get; init; }
}

public sealed class OrderDetail
{
    public long Id { get; init; }
    public string Status { get; init; } = "";
    public decimal TotalAmount { get; init; }
    public DateTime PlacedAt { get; init; }
    public CustomerRef? Customer { get; set; }
}

public sealed class CustomerRef
{
    public long Id { get; init; }
    public string Name { get; init; } = "";
    public string Email { get; init; } = "";
}

When to use it

  • Serve GET /customers/{id}/orders from a hand-tuned SQL query with a covering index, mapped straight to a response DTO
  • Build a monthly revenue report with window functions and a CTE that EF Core LINQ cannot express cleanly
  • Load an order page (header, lines, payments) in one database round trip with QueryMultipleAsync

More examples

One-to-many multi-mapping without duplicate parents

The join returns one row per invoice line, so the invoice columns repeat. Dapper builds a new <code>Invoice</code> for every row; the dictionary keeps the first instance and appends lines to it, and the method returns <code>byId.Values</code> rather than the query result, which would hold the same invoice once per line. For large fan-outs, two queries through <code>QueryMultipleAsync</code> move less data than a join.

Example · csharp
using Dapper;
using Npgsql;

// Column-to-member matching assumes DefaultTypeMap.MatchNamesWithUnderscores = true at start-up.
public sealed class InvoiceReader(NpgsqlDataSource dataSource)
{
    public async Task<IReadOnlyList<Invoice>> ForCustomerAsync(long customerId, CancellationToken ct)
    {
        const string sql = """
            SELECT i.id, i.number, i.issued_on, i.total,
                   l.id, l.sku, l.quantity, l.unit_price
            FROM invoices i
            JOIN invoice_lines l ON l.invoice_id = i.id
            WHERE i.customer_id = @customerId
            ORDER BY i.issued_on DESC, l.id
            """;

        var byId = new Dictionary<long, Invoice>();
        await using var connection = await dataSource.OpenConnectionAsync(ct);
        await connection.QueryAsync<Invoice, InvoiceLine, Invoice>(
            new CommandDefinition(sql, new { customerId }, cancellationToken: ct),
            (invoice, line) =>
            {
                // Each row carries a fresh copy of the invoice columns; keep the first one.
                if (!byId.TryGetValue(invoice.Id, out var existing))
                {
                    existing = invoice;
                    byId.Add(invoice.Id, existing);
                }
                existing.Lines.Add(line);
                return existing;
            },
            splitOn: "id");

        return byId.Values.ToList();
    }
}

public sealed class Invoice
{
    public long Id { get; init; }
    public string Number { get; init; } = "";
    public DateTime IssuedOn { get; init; }
    public decimal Total { get; init; }
    public List<InvoiceLine> Lines { get; } = [];
}

public sealed class InvoiceLine
{
    public long Id { get; init; }
    public string Sku { get; init; } = "";
    public int Quantity { get; init; }
    public decimal UnitPrice { get; init; }
}

Lists of ids: = ANY(@ids) instead of IN @ids

With <code>= ANY(@skus)</code> Npgsql sends the array as a single <code>text[]</code> parameter, so 3 SKUs and 300 SKUs share one statement and one plan. The positional record works here because the select list order (<code>sku</code>, <code>on_hand</code>) matches the constructor and the types match exactly (<code>text</code> to string, <code>integer</code> to int). PostgreSQL also caps a statement at 65,535 parameters, which <code>IN</code> expansion of a large list can hit.

Example · csharp
using Dapper;
using Npgsql;

// Column-to-member matching assumes DefaultTypeMap.MatchNamesWithUnderscores = true at start-up.
public sealed record StockLevel(string Sku, int OnHand);

public sealed class StockQueries(NpgsqlDataSource dataSource)
{
    // Pitfall: Dapper rewrites IN @skus into IN (@skus1, @skus2, ...). Every list length
    // is a different SQL text, so the server plans it again and Dapper caches another entry.
    public async Task<IReadOnlyList<StockLevel>> SlowAsync(string[] skus, CancellationToken ct)
    {
        await using var connection = await dataSource.OpenConnectionAsync(ct);
        var rows = await connection.QueryAsync<StockLevel>(new CommandDefinition(
            "SELECT sku, on_hand FROM inventory WHERE sku IN @skus", new { skus }, cancellationToken: ct));
        return rows.AsList();
    }

    // Fix on PostgreSQL: one text[] parameter, one statement for any list length.
    public async Task<IReadOnlyList<StockLevel>> FastAsync(string[] skus, CancellationToken ct)
    {
        await using var connection = await dataSource.OpenConnectionAsync(ct);
        var rows = await connection.QueryAsync<StockLevel>(new CommandDefinition(
            "SELECT sku, on_hand FROM inventory WHERE sku = ANY(@skus)", new { skus }, cancellationToken: ct));
        return rows.AsList();
    }
}

One round trip for an order page with QueryMultipleAsync

Three statements go to the server in one command and come back as three result sets, read strictly in order through the <code>GridReader</code>. Returning early after the header is safe because disposing the grid discards the unread sets. This replaces three round trips or a wide join that repeats the order columns for every line and payment combination.

Example · csharp
using Dapper;
using Npgsql;

// Column-to-member matching assumes DefaultTypeMap.MatchNamesWithUnderscores = true at start-up.
public sealed record OrderHeader(long Id, string Status, decimal TotalAmount);
public sealed record OrderLineRow(string Sku, int Quantity, decimal UnitPrice);
public sealed record PaymentRow(string Provider, decimal Amount, string State);
public sealed record OrderPage(OrderHeader Header, IReadOnlyList<OrderLineRow> Lines, IReadOnlyList<PaymentRow> Payments);

public sealed class OrderPageQuery(NpgsqlDataSource dataSource)
{
    public async Task<OrderPage?> LoadAsync(long orderId, CancellationToken ct)
    {
        const string sql = """
            SELECT id, status, total_amount FROM orders WHERE id = @orderId;
            SELECT sku, quantity, unit_price FROM order_lines WHERE order_id = @orderId ORDER BY id;
            SELECT provider, amount, state FROM payments WHERE order_id = @orderId ORDER BY created_at;
            """;
        await using var connection = await dataSource.OpenConnectionAsync(ct);
        await using var grid = await connection.QueryMultipleAsync(
            new CommandDefinition(sql, new { orderId }, cancellationToken: ct));

        var header = await grid.ReadSingleOrDefaultAsync<OrderHeader>();
        if (header is null) return null;
        var lines = (await grid.ReadAsync<OrderLineRow>()).AsList();
        var payments = (await grid.ReadAsync<PaymentRow>()).AsList();
        return new OrderPage(header, lines, payments);
    }
}

Discussion

  • Be the first to comment on this lesson.