Dapper and ADO.NET give you direct control over the SQL your .NET application sends, with very little overhead between your code and the database driver. ADO.NET is the foundation every .NET data library builds on, and Dapper is a thin micro-ORM that maps query results to objects while leaving the SQL to you. This guide is for developers who know EF Core and want to decide when to go lower, then do it safely: connections and pooling, readers, Dapper's query and multi-mapping APIs, parameters and SQL injection, transactions, stored procedures, Dapper.AOT, bulk loading with SqlBulkCopy, and running Dapper alongside EF Core.

What Are ADO.NET and Dapper?#

ADO.NET is the set of abstract types in System.Data.Common, including DbConnection, DbCommand, DbDataReader, DbParameter and DbTransaction, plus a provider package per database that implements them. For SQL Server that provider is Microsoft.Data.SqlClient; for PostgreSQL it is Npgsql. EF Core itself executes every query through these types, so anything EF Core can do, ADO.NET can do with more code.

Dapper, originally built for Stack Overflow, adds extension methods such as Query<T>, QueryFirstOrDefault<T>, Execute and QueryMultiple to any IDbConnection. You write the SQL; Dapper creates parameters from an object, executes the command and materializes rows through cached, generated mapping code. There is no change tracking, no LINQ translation and no migrations. That absence is the point: what you write is what runs.

When to Go Below an ORM#

Dropping below EF Core is justified when you can name the reason:

  • Hot paths with tight latency budgets, where you have measured that EF Core's overhead matters.
  • Reporting and analytics queries that rely on window functions, CTEs, pivots or query hints that LINQ expresses poorly.
  • Bulk loads of thousands to millions of rows, which need provider bulk APIs rather than row-by-row inserts.
  • Provider-specific features such as table-valued parameters, PostgreSQL COPY or vendor JSON functions.
  • Stored-procedure-centric databases you do not own and cannot model cleanly.
  • Native AOT or very fast startup, where Dapper.AOT or plain ADO.NET avoids runtime code generation.

If the real problem is an N+1 query or a missing index, fix that first; EF Core Performance Tuning shows how. Most teams keep EF Core as the default and use Dapper or ADO.NET for a small, well-understood set of queries.

How ADO.NET Works: Connections, Commands, Readers and Pooling#

Every database interaction follows the same shape. You open a connection, create a command with SQL text and parameters, execute it, and either read rows from a data reader or receive an affected-row count or scalar. Understanding what each step costs explains most performance advice:

  • Opening a connection is cheap because of connection pooling. The driver keeps a pool per unique connection string (and per Windows identity with integrated security). Open takes an idle physical connection from the pool, and Close or Dispose returns it.
  • Pool limits matter under load. SQL Server's default Max Pool Size is 100. When every connection is busy, new requests wait until the connect timeout (15 seconds by default) and then fail, so a pool timeout usually means connections are leaking or held too long, not that the pool is too small.
  • Commands carry SQL text, parameters, a timeout and optionally a transaction. Parameterized commands let the server reuse cached execution plans.
  • Data readers stream rows forward-only. Nothing is buffered unless you buffer it, which makes readers the most memory-efficient way to process large results.

Always dispose connections, commands and readers with await using. A connection that is never closed is never returned to the pool, and the pool will eventually run dry. Idle pooled connections are closed after several minutes, and the pool is cleared automatically after fatal errors such as a failover.

Getting Started with ADO.NET and Dapper#

The raw ADO.NET version of a query shows what every higher-level library does for you:

C#
using System.Data;
using Microsoft.Data.SqlClient;

public sealed record OrderRow(int Id, string Number, decimal Total);

public static async Task<List<OrderRow>> GetOrdersAsync(
    string connectionString, int customerId, DateTimeOffset since, CancellationToken ct)
{
    await using var connection = new SqlConnection(connectionString);
    await connection.OpenAsync(ct);

    await using var command = connection.CreateCommand();
    command.CommandText = """
        SELECT Id, Number, Total
        FROM sales.Orders
        WHERE CustomerId = @customerId AND PlacedAt >= @since
        """;
    command.Parameters.Add("@customerId", SqlDbType.Int).Value = customerId;
    command.Parameters.Add("@since", SqlDbType.DateTimeOffset).Value = since;

    var orders = new List<OrderRow>();
    await using var reader = await command.ExecuteReaderAsync(ct);
    int id = reader.GetOrdinal("Id"), number = reader.GetOrdinal("Number");
    int total = reader.GetOrdinal("Total");

    while (await reader.ReadAsync(ct))
    {
        orders.Add(new OrderRow(reader.GetInt32(id), reader.GetString(number),
            reader.GetDecimal(total)));
    }

    return orders;
}

Declaring parameter types explicitly avoids the type inference of AddWithValue, which can send nvarchar for a varchar column and force an implicit conversion that blocks index seeks. Resolving ordinals once, outside the loop, avoids a name lookup per row.

With Dapper, the same query shrinks to one call. In an ASP.NET Core app, register something that hands out connections and use it from endpoints:

C#
using Dapper;
using Microsoft.Data.SqlClient;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(
    new SqlConnectionFactory(builder.Configuration.GetConnectionString("Shop")!));

var app = builder.Build();

app.MapGet("/orders/{id:int}", async (int id, SqlConnectionFactory db, CancellationToken ct) =>
{
    await using var connection = await db.OpenAsync(ct);
    var order = await connection.QuerySingleOrDefaultAsync<OrderDto>(new CommandDefinition(
        "SELECT Id, Number, Status, Total FROM sales.Orders WHERE Id = @id",
        new { id }, cancellationToken: ct));

    return order is null ? Results.NotFound() : Results.Ok(order);
});

app.Run();

public sealed record OrderDto(int Id, string Number, string Status, decimal Total);

public sealed class SqlConnectionFactory(string connectionString)
{
    public async Task<SqlConnection> OpenAsync(CancellationToken ct)
    {
        var connection = new SqlConnection(connectionString);
        try
        {
            await connection.OpenAsync(ct);
            return connection;
        }
        catch
        {
            await connection.DisposeAsync();
            throw;
        }
    }
}

Dapper's ...Async overloads that take a CommandDefinition are the ones that accept a CancellationToken, so prefer them in server code. For PostgreSQL, Npgsql 7 and later provide NpgsqlDataSource, a thread-safe object that owns the pool and hands out connections; register one per database and inject it instead of a hand-written factory. Details are in PostgreSQL with .NET.

Querying with Dapper: Query, Execute and QueryMultiple#

Dapper's surface is small. Query<T> returns rows, QueryFirst, QueryFirstOrDefault, QuerySingle and QuerySingleOrDefault return one row with the matching LINQ semantics, ExecuteScalar<T> returns one value, and Execute returns the affected-row count. By default, Query<T> buffers the whole result into a list before returning, which releases the connection and any shared locks quickly. For very large results, QueryUnbufferedAsync<T> returns an IAsyncEnumerable<T> that streams rows; cancel it with WithCancellation(ct).

QueryMultiple executes a batch that returns several result sets in one round trip, which is ideal for a page that needs a header, a list and a count:

C#
await using var grid = await connection.QueryMultipleAsync(new CommandDefinition("""
    SELECT Id, Name, Email FROM sales.Customers WHERE Id = @id;

    SELECT TOP (20) Id, Number, PlacedAt, Total
    FROM sales.Orders WHERE CustomerId = @id ORDER BY PlacedAt DESC;

    SELECT COUNT(*) FROM sales.Orders WHERE CustomerId = @id;
    """, new { id }, cancellationToken: ct));

var customer = await grid.ReadSingleOrDefaultAsync<CustomerRow>();
var recentOrders = (await grid.ReadAsync<OrderSummaryRow>()).AsList();
var orderCount = await grid.ReadSingleAsync<int>();

Read the grids in order; each Read call consumes the next result set. Dapper maps columns to properties or constructor parameters by name, case-insensitively, so alias columns to match your types rather than renaming properties to match the database.

Multi-Mapping: Joining Rows into Object Graphs#

A join returns flat rows, but your code usually wants an object with its children. Multi-mapping splits each row into several objects at a column you choose, and a mapping function stitches them together. The splitOn argument names the column where the next object begins, which defaults to Id.

C#
const string sql = """
    SELECT o.Id, o.Number, o.PlacedAt,
           l.Id, l.Sku, l.Quantity, l.UnitPrice
    FROM sales.Orders o
    JOIN sales.OrderLines l ON l.OrderId = o.Id
    WHERE o.CustomerId = @customerId
    ORDER BY o.Id
    """;

var orders = new Dictionary<int, OrderWithLines>();

await connection.QueryAsync<OrderWithLines, OrderLineRow, OrderWithLines>(
    new CommandDefinition(sql, new { customerId }, cancellationToken: ct),
    (order, line) =>
    {
        if (!orders.TryGetValue(order.Id, out var existing))
        {
            existing = order;
            orders.Add(order.Id, existing);
        }

        existing.Lines.Add(line);
        return existing;
    },
    splitOn: "Id");

public sealed class OrderWithLines
{
    public int Id { get; set; }
    public string Number { get; set; } = "";
    public DateTimeOffset PlacedAt { get; set; }
    public List<OrderLineRow> Lines { get; } = [];
}

public sealed class OrderLineRow
{
    public int Id { get; set; }
    public string Sku { get; set; } = "";
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

The dictionary de-duplicates parents, because the join repeats order columns on every line. This is exactly the data duplication EF Core's split queries avoid, so for wide parent rows or several child collections, QueryMultiple with one query per level is often cheaper than a single join.

Parameters and SQL Injection Prevention#

SQL injection remains one of the most damaging web vulnerabilities, and data-access code below an ORM is where it usually appears. The rule is absolute: values travel as parameters, never as concatenated strings. Dapper makes that the easy path, because every property of the parameter object becomes a DbParameter.

C#
// Values are always parameters, including list expansion: IN @ids becomes IN (@ids1, @ids2, ...)
var products = await connection.QueryAsync<ProductRow>(new CommandDefinition(
    "SELECT Id, Name, Price FROM catalog.Products WHERE Id IN @ids AND Name LIKE @pattern",
    new { ids = selectedIds, pattern = $"%{search}%" }, cancellationToken: ct));

// Dynamic filters: append fixed SQL fragments, bind every value
var sql = new StringBuilder("SELECT Id, Name, Price FROM catalog.Products WHERE 1 = 1");
var parameters = new DynamicParameters();
if (minPrice is not null)
{
    sql.Append(" AND Price >= @minPrice");
    parameters.Add("minPrice", minPrice, DbType.Decimal);
}

// Identifiers cannot be parameters: map user input to an allow-list
var orderBy = sortField switch
{
    "name" => "Name",
    "price" => "Price",
    _ => "Id",
};
sql.Append($" ORDER BY {orderBy}");

var filtered = await connection.QueryAsync<ProductRow>(
    new CommandDefinition(sql.ToString(), parameters, cancellationToken: ct));

// varchar column: send an ANSI string so SQL Server can seek the index
var bySku = await connection.QuerySingleOrDefaultAsync<ProductRow>(new CommandDefinition(
    "SELECT Id, Name, Price FROM catalog.Products WHERE Sku = @sku",
    new { sku = new DbString { Value = sku, IsAnsi = true, Length = 32 } },
    cancellationToken: ct));

Column names, sort directions and table names cannot be parameterized, so the only safe approach is choosing them from a fixed set in code, as the switch does. List expansion is convenient, but SQL Server allows at most 2,100 parameters per request, so pass large key sets through a table-valued parameter or a JSON array instead. For the wider threat model, see OWASP Top 10 for .NET Developers.

Transactions and Stored Procedures#

A transaction in ADO.NET belongs to a connection, and every command that should take part must reference it. With Dapper, pass the transaction through the CommandDefinition or the transaction argument; forgetting it on one command is a classic bug, and SqlClient throws when a command on a connection with a pending local transaction does not reference it.

C#
await using var connection = await factory.OpenAsync(ct);
await using var tx = await connection.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct);

var orderId = await connection.ExecuteScalarAsync<int>(new CommandDefinition("""
    INSERT INTO sales.Orders (Number, CustomerId, PlacedAt, Total)
    OUTPUT INSERTED.Id
    VALUES (@Number, @CustomerId, @PlacedAt, @Total)
    """, newOrder, tx, cancellationToken: ct));

// Passing a sequence runs the statement once per element: convenient, not a bulk insert
await connection.ExecuteAsync(new CommandDefinition("""
    INSERT INTO sales.OrderLines (OrderId, Sku, Quantity, UnitPrice)
    VALUES (@OrderId, @Sku, @Quantity, @UnitPrice)
    """, newLines.Select(l => new { OrderId = orderId, l.Sku, l.Quantity, l.UnitPrice }),
    tx, cancellationToken: ct));

// Stored procedure with an output parameter, in the same transaction
var p = new DynamicParameters(new { customerId = newOrder.CustomerId });
p.Add("openOrders", dbType: DbType.Int32, direction: ParameterDirection.Output);
await connection.ExecuteAsync(new CommandDefinition(
    "sales.RefreshCustomerStats", p, tx, commandType: CommandType.StoredProcedure,
    cancellationToken: ct));
var openOrders = p.Get<int>("openOrders");

await tx.CommitAsync(ct);   // disposing without commit rolls back

Keep transactions short and never await user input or remote HTTP calls inside one, because locks are held until commit. Choose the isolation level deliberately: ReadCommitted is SQL Server's default, while Serializable prevents more anomalies at the cost of more blocking and deadlocks. Output parameters are populated only after the command completes and any result set has been fully read.

Dapper.AOT: Build-Time Code Generation#

Classic Dapper generates IL at runtime the first time it sees a query shape. That works well on the JIT, but it is invisible to the trimmer and impossible under Native AOT. Dapper.AOT, from the same maintainers, uses C# interceptors to replace your Dapper calls with code generated at build time, without changing your call sites. It ships in two packages: Dapper.Advisor, which only adds analyzers that critique your SQL and Dapper usage, and Dapper.AOT, which adds the analyzers plus the generator.

XML
<ItemGroup>
  <PackageReference Include="Dapper" Version="2.1.89" />
  <PackageReference Include="Dapper.AOT" Version="1.1.0" />
</ItemGroup>

<PropertyGroup>
  <!-- Opt in to interceptors generated in the Dapper.AOT namespace -->
  <InterceptorsNamespaces>$(InterceptorsNamespaces);Dapper.AOT</InterceptorsNamespaces>
  <PublishAot>true</PublishAot>
</PropertyGroup>

Installing the package does not change behavior by itself. You opt in with [module: DapperAot] in any C# file, or with [DapperAot] on individual types and methods. A .NET 8 or later SDK is required to build, but the project can still target older frameworks. The .NET 8 SDK only understands the older InterceptorsPreviewNamespaces property, so set that one to the same value if you still build with it. The analyzers are useful even without AOT: they flag, for example, SELECT * when they know the SQL dialect.

Know the limits before you commit. Only direct, inline Dapper calls with generic methods such as Query<T> are intercepted. QueryMultiple is currently not supported and keeps running on classic Dapper, which works under the JIT but is not AOT-safe. Runtime configuration such as SqlMapper.AddTypeHandler is not seen by the generator, so type handlers are declared with a module-level TypeHandler attribute instead. The maintainers ask you to test under a real AOT publish, and your ADO.NET provider must also support Native AOT. For the bigger picture, see Native AOT and Trimming in .NET.

Bulk Operations with SqlBulkCopy#

Row-by-row inserts, even batched ones, top out quickly. SqlBulkCopy streams rows to SQL Server using the same bulk-load protocol as the bcp tool, and it is typically the fastest way to load large volumes from .NET. Its source can be a DataTable, a DataRow[] or any IDataReader, which lets you pipe rows from another database or a file parser without materializing them all.

C#
public static async Task LoadPricesAsync(
    SqlConnection connection, IEnumerable<PriceRow> prices, CancellationToken ct)
{
    using var table = new DataTable();
    table.Columns.Add("Sku", typeof(string));
    table.Columns.Add("Price", typeof(decimal));
    table.Columns.Add("ValidFrom", typeof(DateTimeOffset));
    foreach (var price in prices)
    {
        table.Rows.Add(price.Sku, price.Price, price.ValidFrom);
    }

    // Load a staging table, then merge in one set-based statement
    using var bulk = new SqlBulkCopy(connection, SqlBulkCopyOptions.TableLock, null)
    {
        DestinationTableName = "catalog.PriceStaging",
        BatchSize = 5_000,
        BulkCopyTimeout = 0,            // 0 means no timeout for long loads
    };
    bulk.ColumnMappings.Add("Sku", "Sku");
    bulk.ColumnMappings.Add("Price", "Price");
    bulk.ColumnMappings.Add("ValidFrom", "ValidFrom");

    await bulk.WriteToServerAsync(table, ct);

    await connection.ExecuteAsync(new CommandDefinition("""
        MERGE catalog.Prices AS t
        USING catalog.PriceStaging AS s ON t.Sku = s.Sku
        WHEN MATCHED THEN UPDATE SET Price = s.Price, ValidFrom = s.ValidFrom
        WHEN NOT MATCHED THEN INSERT (Sku, Price, ValidFrom)
            VALUES (s.Sku, s.Price, s.ValidFrom);
        TRUNCATE TABLE catalog.PriceStaging;
        """, cancellationToken: ct));
}

By default each bulk copy is its own non-transacted operation: batches already written stay committed if a later batch fails. Use SqlBulkCopyOptions.UseInternalTransaction for per-batch transactions, or pass an existing SqlTransaction to include the load in a larger unit of work. Bulk copy also skips constraint checks and triggers unless you set CheckConstraints and FireTriggers. Loading into a staging table and merging keeps the target table consistent and lets you validate data first. For very large sources, pass an IDataReader and set EnableStreaming so rows are not buffered. PostgreSQL's equivalent is binary COPY, exposed by Npgsql through BeginBinaryImport, and Dapper.AOT's TypeAccessor.CreateDataReader can turn any IEnumerable<T> into a reader for SqlBulkCopy.

Combining Dapper with EF Core#

You do not have to choose one library per application. EF Core exposes its underlying connection and transaction, so Dapper can run on the same connection inside the same transaction:

C#
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

await using var tx = await db.Database.BeginTransactionAsync(ct);

db.Orders.Add(order);
await db.SaveChangesAsync(ct);                        // EF Core: tracked domain write

var connection = db.Database.GetDbConnection();       // already open inside the transaction
await connection.ExecuteAsync(new CommandDefinition("""
    UPDATE sales.CustomerStats
    SET OrderCount = OrderCount + 1, LastOrderAt = @PlacedAt
    WHERE CustomerId = @CustomerId
    """, new { order.CustomerId, order.PlacedAt }, tx.GetDbTransaction(),
    cancellationToken: ct));                           // Dapper: hand-tuned statement

await tx.CommitAsync(ct);

Two details matter. If the context uses a retrying execution strategy such as EnableRetryOnFailure, EF Core refuses user-initiated transactions unless you run the whole block inside db.Database.CreateExecutionStrategy().ExecuteAsync(...). And before adding Dapper at all, check whether EF Core's own raw SQL APIs are enough: SqlQuery<T> maps SQL results to unmapped types since EF Core 8, and ExecuteSqlAsync runs parameterized commands. A common architecture uses EF Core for commands and Dapper for read-side queries, a pattern covered in the Entity Framework Core guide.

Best Practices#

  • Dispose everything with await using, and open connections as late and close them as early as possible.
  • Parameterize every value and choose identifiers from allow-lists; never concatenate user input.
  • Declare parameter types for strings, especially varchar columns, using DbString or explicit SqlDbType.
  • Use the CommandDefinition overloads so cancellation tokens reach the driver.
  • Select explicit columns instead of SELECT *, so schema changes do not silently break mappings.
  • Buffer small results, stream large ones with QueryUnbufferedAsync or a data reader.
  • Keep SQL close to its code in constants or embedded resources, and test it against a real database.
  • Use bulk APIs for large writes and a staging table for upserts.
  • Keep driver packages current. Microsoft.Data.SqlClient 7.0 moved Entra ID authentication into the separate Microsoft.Data.SqlClient.Extensions.Azure package, so add it when upgrading apps that use managed identities.

Common Pitfalls#

  • Connection leaks from missing using statements, which surface later as pool timeouts.
  • String concatenation for "just this one filter," which is how most injection bugs start.
  • Implicit conversions from nvarchar parameters against varchar columns, which turn seeks into scans.
  • Assuming Execute with a list is a bulk insert. It runs one statement per item.
  • Forgetting the transaction on one command inside a transactional block.
  • Generating unique SQL strings per call by embedding values, which bloats Dapper's query cache and the server's plan cache.
  • Mixing up First and Single. QuerySingle throws on multiple rows, which is usually the check you want for key lookups.

EF Core vs Dapper vs Dapper.AOT vs ADO.NET#

AspectEF CoreDapperDapper.AOTRaw ADO.NET
SQL authoringLINQ, raw SQL optionalHand-writtenHand-writtenHand-written
Result mappingModel-drivenRuntime-generated ILBuild-time generated codeManual
Change tracking and migrationsYesNoNoNo
Native AOTExperimentalNoYes, with limitationsYes, if the provider supports it
Typical overheadLow to moderateVery lowVery lowLowest
Code volumeLowest for CRUDLowLowHigh
Best forDomain models and everyday CRUDRead-heavy and reporting queriesDapper workloads that need AOT or trimmingBulk loads, streaming, driver-specific features

Dapper's own published benchmarks, run with BenchmarkDotNet on .NET 8, put QueryFirstOrDefault<T> within about 12% of hand-written SqlCommand code for a single-row read, which is why most teams never need to go all the way down to raw ADO.NET.

Frequently Asked Questions#

Is Dapper faster than EF Core?#

For simple reads, Dapper usually has less overhead because it does no LINQ translation or change tracking. The gap has narrowed considerably, and a well-written EF Core query with projection and AsNoTracking is often close enough that database time dominates. Choose Dapper for control and predictability of SQL, not only for speed.

Does Dapper protect against SQL injection?#

Dapper sends the properties of your parameter object as real database parameters, so values bound that way are safe. It cannot protect SQL you build by concatenating user input, and it cannot parameterize identifiers such as column names. Use allow-lists for anything that is not a value.

Is Dapper.AOT ready for production use?#

Dapper.AOT is a released 1.x package, and its maintainers state that performance is at least on par with classic Dapper. However, not every Dapper API is supported, notably QueryMultiple, and unsupported calls fall back to runtime code generation. Enable it, fix its analyzer warnings, and test your application under an actual Native AOT publish before relying on it.

How many connections should my connection pool have?#

Usually the default. SQL Server's default maximum of 100 connections per pool is plenty for most services, because each connection is held only for the duration of a query. If you hit pool timeouts, look for leaked connections, long transactions or synchronous blocking before raising the limit, and remember that every application instance has its own pool.

Can I use Dapper and EF Core in the same transaction?#

Yes. Begin the transaction through EF Core, take the connection from Database.GetDbConnection() and the transaction from GetDbTransaction(), and pass both to Dapper. If you use a retrying execution strategy, wrap the whole unit of work in the strategy's ExecuteAsync so it can be retried as a whole.

Summary#

  • ADO.NET is the foundation; Dapper adds fast object mapping on top while leaving SQL in your hands.
  • Go below EF Core for measured hot paths, complex reporting SQL, bulk loads and provider-specific features.
  • Parameterize every value, use allow-lists for identifiers, and pass transactions and cancellation tokens explicitly.
  • Dapper.AOT brings build-time generation and analyzers, with documented gaps such as QueryMultiple.
  • Use SqlBulkCopy or PostgreSQL COPY for large loads, and share EF Core's connection and transaction when mixing libraries.

Further Reading#