Skip to content

Dapper (C# / .NET)

A set of extension methods on ADO.NET's IDbConnection that run the raw SQL you write and materialize result rows into objects fast, with automatic parameter binding — the archetypal micro-ORM: raw SQL + injection-safe parameters + an IL-emitted, cached row-to-object mapper, and deliberately nothing above that rung (no query generation, no schema, no change tracking).

FieldValue
LanguageC# / .NET (targets net461, netstandard2.0, net8.0, net10.0)
LicenseApache-2.0
RepositoryDapperLib/Dapper
Documentationdapperlib.github.io/Dapper · Readme.md
CategorySafe-SQL / micro-mapper — raw SQL + fast object materialization; no query DSL, no ORM
Abstraction levelSafe-SQL / micro-mapper rung — extension methods on IDbConnection; parameters bind automatically, rows hydrate into typed objects
Query modelRaw SQL string you write, with @name placeholders + anonymous-object / DynamicParameters binding
Effect/async modelBlocking and async (Task / Task<T>); no effect value, exception-based errors
BackendsAny ADO.NET provider — SQL Server, PostgreSQL, MySQL/MariaDB, SQLite, Oracle, Firebird, SQL CE
First release≈2011 (web-attested; open-sourced out of Stack Overflow)
Latest version2.1 line (the pinned tree's version.json); NuGet 2.1.x (web-attested)

NOTE

Dapper sits on the safe-SQL / micro-mapper rung of the abstraction ladder — one step above a bare driver (ADO.NET itself), and far below a full ORM. You write every SQL string; Dapper's job is only to (1) turn a plain object's properties into real ADO.NET parameters and (2) turn the returned columns into typed objects, both as fast as hand-written code. It is this survey's data point for the imperative, exception-based, provider-agnostic micro-mapper in a mainstream OO ecosystem — the .NET analogue of hasql (Haskell) and JDBI (Java), and the deliberate antithesis of the heavier .NET options EF Core (full ORM) and linq2db (LINQ query builder). See concepts for shared vocabulary.


Overview

What it solves

Dapper removes the ceremony of ADO.NET without moving up to an ORM. Raw ADO.NET makes you create a DbCommand, add each DbParameter by hand, call ExecuteReader, loop the DbDataReader, and pull each column out by ordinal into a hand-written object — boilerplate that is verbose, error-prone, and easy to get subtly wrong. Dapper collapses all of that into one extension-method call while keeping the SQL entirely in your hands. The project's own one-line pitch (docs/readme.md):

"Dapper is a simple micro-ORM used to simplify working with ADO.NET; if you like SQL but dislike the boilerplate of ADO.NET: Dapper is for you!"

The Readme.md states the shape of the library precisely — it is a bag of extension methods, not a framework you inherit from (Readme.md):

"Dapper is a NuGet library that you can add in to your project that will enhance your ADO.NET connections via extension methods on your DbConnection instance. This provides a simple and efficient API for invoking SQL, with support for both synchronous and asynchronous data access, and allows both buffered and non-buffered queries."

The whole public surface is a static partial class SqlMapper whose methods extend IDbConnection (SqlMapper.cs); the three load-bearing ones, from the Readme.md (Readme.md):

csharp
// insert/update/delete etc
var count  = connection.Execute(sql [, args]);

// multi-row query
IEnumerable<T> rows = connection.Query<T>(sql [, args]);

// single-row query ({Single|First}[OrDefault])
T row = connection.QuerySingle<T>(sql [, args]);

ExecuteScalar<T> (first cell), QueryMultiple (several result grids), and their …Async twins round the set out. Everything hangs off an existing, caller-owned IDbConnection; the connection object is this, never something Dapper constructs.

Design philosophy

Three properties define Dapper, each visible in its own metadata and docs.

It is a mapper, not an ORM — "simple" and "light weight" are load-bearing words. The Readme.md title is "Dapper - a simple object mapper for .Net" (Readme.md); the crate-level docstring calls it "Dapper, a light weight object mapper for ADO.NET" (SqlMapper.cs); and the NuGet description positions it as "A high performance Micro-ORM supporting SQL Server, MySQL, Sqlite, SqlCE, Firebird etc." (Dapper.csproj). The absence of ORM machinery is a stated feature, not a gap (Readme.md):

"Dapper's simplicity means that many features that ORMs ship with are stripped out. It worries about the 95% scenario, and gives you the tools you need most of the time. It doesn't attempt to solve every problem."

Performance is the reason it exists. Dapper came out of Stack Overflow, where it still runs in production (Readme.md): "Dapper was originally developed for and by Stack Overflow, but is F/OSS" and "Dapper is in production use at Stack Overflow". The Readme.md opens its benchmark section with "A key feature of Dapper is performance" and shows its Query<T>/QueryFirstOrDefault<T> mapping landing within a few microseconds of hand-coded SqlCommand and well ahead of EF Core, NHibernate, and EF 6 on the same SELECT. The speed comes from compiling a per-shape materializer once and caching it (below), not from any clever protocol work — Dapper speaks whatever ADO.NET speaks.

Provider-agnostic by construction. Dapper has no database-specific code; it works over any ADO.NET provider (Readme.md):

"Dapper has no DB specific implementation details, it works across all .NET ADO providers including SQLite, SQL CE, Firebird, Oracle, MariaDB, MySQL, PostgreSQL and SQL Server."

That is the flip side of "you write the SQL": Dapper never generates or rewrites your query for a dialect (with two small mechanical exceptions — list expansion and literal replacement, below), so portability and correctness of the SQL itself are your responsibility.


Connection, pooling & resource lifetime

Dapper owns no connection lifecycle. Every entry point is this IDbConnection cnn — you pass a connection you already have, from whatever pool your ADO.NET provider manages (SqlConnection, NpgsqlConnection, …). Connection pooling in .NET lives in the provider's DbConnection, keyed off the connection string, and Dapper leaves it entirely alone.

The one lifetime nicety Dapper adds is open/close bracketing: if it is handed a closed connection it opens it, runs, and closes it again; if it is handed an open one it leaves it open. In the buffered Query<T> path this is visible as a wasClosed check followed by if (wasClosed) cnn.Open();, with the reader created under CommandBehavior.CloseConnection so an unbuffered enumerator closes the connection when the caller finishes iterating (SqlMapper.cs). There is no lease/return abstraction, no Scope/Resource, and no scoped acquire/release of the kind the effect systems model — resource safety is the ADO.NET using-block discipline of the caller.

CommandDefinition is the value that bundles "the key aspects of a sql operation" (CommandDefinition.cs) — SQL text, parameters, an optional IDbTransaction, a timeout, a CommandType, buffering flags, and a CancellationToken. Its SetupCommand creates the DbCommand, applies the parameter reader, and — crucially for transactions — just assigns cmd.Transaction = Transaction if one was supplied (CommandDefinition.cs).

Query construction & injection safety

This is Dapper's centre of gravity, and the model is: you write raw SQL; Dapper turns your parameter object into real ADO.NET DbParameters and never interpolates a value into the SQL text.

Parameters come from a plain object's properties. You pass an anonymous type (or POCO, or Dictionary<string,object>, or DynamicParameters) as param; Dapper matches each @name placeholder in your SQL to a property of that object and adds a DbParameter for it. From the Readme.md (Readme.md):

"Parameters are usually passed in as anonymous classes. This allows you to name your parameters easily and gives you the ability to simply cut-and-paste SQL snippets and run them in your db platform's Query analyzer."

csharp
// Readme.md — @Age and @Id are added as DbParameters, never spliced into the text
var guid = Guid.NewGuid();
var dog = connection.Query<Dog>("select Age = @Age, Id = @Id",
                                new { Age = (int?)null, Id = guid });

Under the hood, Dapper IL-emits a parameter generator (CreateParamInfoGenerator) that, for each property, calls command.CreateParameter(), sets ParameterName/Value/DbType, and command.Parameters.Add(...) (SqlMapper.cs). The value therefore travels the ADO.NET parameter channel — a real DbParameter bound to a placeholder — so SQL injection is structurally impossible for it: the value is never SQL text. Dapper offers no string-interpolation API for parameter values; there is nothing analogous to a sql.unsafe splice for a value. The generator also trims parameters not mentioned in the SQL (for CommandType.Text), so an over-broad parameter object is harmless.

DynamicParameters is the escape hatch for dynamically-assembled SQL — still parameterized. When you build the SQL string at runtime (variable WHERE predicates, stored-proc out parameters), you accumulate parameters in a DynamicParameters bag — "A bag of parameters that can be passed to the Dapper Query and Execute methods" (DynamicParameters.cs). The Readme.md is explicit that this keeps you on the safe path (Readme.md):

"Parameters can also be built up dynamically using the DynamicParameters class. This allows for building a dynamic SQL statement while still using parameters for safety and performance."

csharp
// Readme.md — dynamic predicate, static safety: each branch adds a *parameter*, not text
var sqlPredicates = new List<string>();
var queryParams = new DynamicParameters();
if (boolExpression)
{
    sqlPredicates.Add("column1 = @param1");
    queryParams.Add("param1", dynamicValue1, System.Data.DbType.Guid);
}

Add(string name, object? value, DbType?, ParameterDirection?, int? size, …) also carries direction (Input/Output/ReturnValue) and size, so stored procedures with output parameters work by binding, then reading back with p.Get<int>("@b") (DynamicParameters.cs, Readme.md).

List expansion: IN @ids becomes IN (@ids1, @ids2, @ids3). A single most-loved convenience — passing any IEnumerable for an IN clause — is a genuine SQL rewrite, but one that adds parameters rather than text (Readme.md):

"Dapper allows you to pass in IEnumerable<int> and will automatically parameterize your query."

csharp
// Readme.md
connection.Query<int>("... where Id in @Ids", new { Ids = new int[] { 1, 2, 3 } });
// becomes: "... where Id in (@Ids1, @Ids2, @Ids3)"  with @Ids1=1, @Ids2=2, @Ids3=3

PackListParameters implements this: it enumerates the list, creates one DbParameter per element (@Ids1, @Ids2, …), and Regex.Replaces the @Ids token in the command text with the parenthesized parameter list; an empty list rewrites to (SELECT @Ids WHERE 1 = 0) so the IN still parses (SqlMapper.cs). Each element is a bound parameter, so the injection guarantee holds. (The code comment records the design choice: "initially we tried TVP, however it performs quite poorly … SQL support up to 2000 params easily in sp_executesql"SqlMapper.cs.)

Literal replacement ({=name}) is the one value-into-text path — and it is bool/numeric only. For query-plan reasons you can inject a bool/numeric member as a literal rather than a parameter (Readme.md): "The literal replacement is not sent as a parameter; this allows better plans and filtered index usage but should usually be used sparingly and after testing." The mechanism ({=member} recognized by a regex, substituted via Format) refuses anything that is not a number or boolean — Format throws NotSupportedException("The type '…' is not supported for SQL literals.") for other types (SqlMapper.cs) — so a {=…} token cannot carry an injection payload even though it does reach the SQL text.

There is no query builder and no compile-time SQL check in core. Dapper never inspects, validates, or generates your SQL. The optional Dapper.SqlBuilder package composes SQL fragments by replacing named placeholders (/**where**/, /**orderby**/) with joined clause text (Dapper.SqlBuilder/SqlBuilder.cs) — string templating over raw fragments, still no type checking of columns or expressions. That places Dapper firmly in the raw-string query model — checked never at compile time — the opposite end of the axis from jOOQ/Diesel (fluent typed builder), the LINQ-to-SQL of EF Core/linq2db (quoted DSL), or the build-time-verified raw SQL of sqlx/sqlc.

Schema, migrations & code generation

Dapper owns no schema, and this is a defining absence. There is no entity/model declaration that is the schema (no code-first), no schema file it treats as truth (no schema-first), no introspection→codegen step (the jOOQ/sqlc move), and no migration runner or DDL versioning anywhere in the tree. You write your CREATE TABLE/ALTER as ordinary SQL and run it through Execute like any other statement; ordering and bookkeeping are entirely external (a separate tool such as FluentMigrator or DbUp, none of them Dapper's concern).

The mapping from a result column to a CLR member is by convention, by name, at runtime — there is no declared schema to check it against, so a typo in a column alias or a renamed property surfaces only when the query runs. The convention itself is configurable at exactly one point: DefaultTypeMap.MatchNamesWithUnderscores"Should column names like User_Id be allowed to match properties/fields like UserId ?" (DefaultTypeMap.cs) — an opt-in snake-case bridge, off by default.

The satellite Dapper.Rainbow package layers thin CRUD helpers on top ("Micro-ORM implemented on Dapper, provides CRUD helpers", Readme.md), and the community Dapper.Contrib adds attribute-driven Insert/Update/Get<T>, but the core library stops at "run this SQL, map these rows."

Type mapping & result decoding

The row-to-object materializer is IL-emitted once per shape and cached. When you call Query<T>, Dapper builds (via System.Reflection.Emit) a DynamicMethod named "Deserialize" + Guid of type Func<DbDataReader, object> that reads each column and assigns it to the matching property/constructor argument of T, then caches the delegate (SqlMapper.cs). The Query<T> contract states the mapping rule (SqlMapper.cs):

"if a basic type (int, string, etc) is queried then the data from the first column is assumed, otherwise an instance is created per row, and a direct column-name===member-name mapping is assumed (case insensitive)."

Member resolution (DefaultTypeMap.GetMember) prefers, in order, an exact-case property, then a case-insensitive property, then backing fields, then — if MatchNamesWithUnderscores — the underscore-stripped name (DefaultTypeMap.cs). Constructor binding is supported (an [ExplicitConstructor] attribute picks one), so immutable records materialize too.

Caching is two-tier and keyed on the actual columns returned. A top-level _queryCache (ConcurrentDictionary<Identity, CacheInfo>) is keyed by an Identity = (SQL text, CommandType, connection string, target Type, parameters Type, grid index, and — for multi-map — the extra types' hash) (SqlMapper.Identity.cs). The compiled deserializer inside a CacheInfo is additionally guarded by a column hash of the reader's returned column names + types; if the columns differ from what the cached delegate was compiled against, Dapper re-emits it (SqlMapper.cs). A second TypeDeserializerCache keyed per Type by a DeserializerKey (the column names + types + bounds) memoizes the emitted Func across queries (SqlMapper.TypeDeserializerCache.cs). Net effect: the expensive IL emission happens once per distinct (type, column-layout), and subsequent rows — and subsequent queries of the same shape — reuse a straight-line delegate. The Readme.md summarizes the trade-off (Readme.md): "Dapper caches information about every query it runs, this allows it to materialize objects quickly and process parameters quickly. The current implementation caches this information in a ConcurrentDictionary object." (With the caveat, same page, that unparameterized SQL generated on the fly can bloat that cache.)

Custom type mapping plugs in through ITypeHandler"Implement this interface to perform custom type-based parameter handling and value parsing", a SetValue(IDbDataParameter, object) / Parse(Type, object) pair (SqlMapper.ITypeHandler.cs) — the seam that maps, say, a JSON column or a value object. There is no composable codec algebra of the kind hasql/skunk expose; a handler is an imperative pair of methods registered globally.

Multi-mapping (splitOn) hydrates a joined row into several objects. For a Post-plus-its- User join you name each type and supply a combining function (Readme.md):

"Dapper allows you to map a single row to multiple objects. This is a key feature if you want to avoid extraneous querying and eager load associations."

csharp
// Readme.md
var data = connection.Query<Post, User, Post>(sql,
    (post, user) => { post.Owner = user; return post; });

The wide row is sliced into per-type column ranges at the splitOn boundary — which defaults to the column named Id (Readme.md): "Dapper is able to split the returned row by making an assumption that your Id columns are named Id or id. If your primary key is different or you would like to split the row at a point other than Id, use the optional splitOn parameter." GenerateDeserializers builds one materializer per slice and the map function stitches them (SqlMapper.cs). Overloads run up to seven types (plus a Type[] form).

QueryMultiple reads several result grids from one command. A GridReader"provides interfaces for reading multiple result sets from a Dapper query" (SqlMapper.GridReader.cs) — yields each grid in turn (Readme.md):

csharp
// Readme.md
using (var multi = connection.QueryMultiple(sql, new { id = selectedId }))
{
   var customer = multi.Read<Customer>().Single();
   var orders   = multi.Read<Order>().ToList();
}

Nullability is CLR nullability: a NULL cell maps to a null reference or a Nullable<T> (int?), and the reader path guards DBNull. Because there is no described schema, nullability is not lifted into the type system the way sqlx/Kysely do — a non-nullable int property fed a NULL column throws at materialization time.

Effect model, transactions & error handling

This is the dimension the survey weights most heavily, and Dapper sits at the imperative, exception-based end — no effect value, no typed error channel.

Sync and async, both eager. Every operation exists in a blocking form (Query<T>, Execute, ExecuteScalar<T>) and a Task-returning form in the SqlMapper.Async partial — QueryAsync<T> returns Task<IEnumerable<T>>, ExecuteAsync returns Task<int>, and (on net5.0+) QueryUnbufferedAsync<T> returns IAsyncEnumerable<T> for streaming (SqlMapper.Async.cs). Both are async in the future/Task sense, not an inspectable description: calling Query<T> runs the query and returns rows (buffered to a List<T> by default), and calling QueryAsync<T> starts it and returns a Task. There is no IO/ConnectionIO/Effect value to compose and interpret at the edge — the contrast with doobie/skunk/Quill, and even with hasql's eager-but-monadic Session. Buffering is a flag: "Dapper's default behavior is to execute your SQL and buffer the entire reader on return", with buffered: false for a lazy IEnumerable<T> (Readme.md).

Transactions are pure ADO.NET pass-through — Dapper adds no transaction abstraction.

IMPORTANT

There is no withTransaction/transaction { … } combinator, no savepoint API, and no isolation-level helper in Dapper — a deliberate finding. You obtain an IDbTransaction the ADO.NET way (conn.BeginTransaction()), pass it as the transaction: argument to each Dapper call, and commit/rollback/Dispose it yourself. The entire integration is one line in CommandDefinition.SetupCommand: if (Transaction is not null) cmd.Transaction = Transaction; (CommandDefinition.cs). Nesting, savepoints, retry-on-serialization- failure, and isolation levels are the provider's IDbTransaction's job, not Dapper's. Contrast the nested-withTransaction/savepoint combinators of the effect systems and even doobie's pluggable Strategy; Dapper is closer to JDBI/database/sql in kind — the transaction is an object you thread through, not a scope the library manages.

Errors are exceptions. Dapper does not model failure as a value. A SQL error, a constraint violation, a decode mismatch, a NULL in a non-nullable slot — all surface as the provider's DbException (SQL Server's SqlException, Npgsql's PostgresException, …) or a Dapper ArgumentException/NotSupportedException/InvalidOperationException, thrown from the call. There is no typed error channel, no Either/Result, no isRetryable flag — catching a unique-violation means a try/catch on the provider exception and inspecting its SQLSTATE/error number, the mainstream .NET idiom. This is the exception-based pole the concepts page contrasts with the effect systems' value-typed errors.

Ecosystem & maturity

Dapper is one of the most-deployed data-access libraries in .NET — battle-tested since it was open-sourced out of Stack Overflow (where it still runs, Readme.md) and authored by Sam Saffron, Marc Gravell, and Nick Craver (Dapper.csproj). It is released under the permissive Apache-2.0 license (License.txt) and multi-targets the .NET Framework, .NET Standard 2.0, and current .NET (net8.0/net10.0, Dapper.csproj), so it runs essentially everywhere .NET does. Development is now community/independently maintained under the DapperLib org, with Dapper Plus (ZZZ Projects) and AWS as named sponsors (Readme.md).

Backends: any ADO.NET provider. Because Dapper has "no DB specific implementation details" (Readme.md), it works with SQL Server (Microsoft.Data.SqlClient), PostgreSQL (Npgsql), MySQL/MariaDB, SQLite, Oracle, Firebird, and SQL CE — anything exposing DbConnection. There is no dialect layer, because there is no SQL generation: dialect differences live in the SQL you write.

The Dapper family is a set of separately-versioned NuGet packages around the core (Readme.md): Dapper (core), Dapper.EntityFramework (EF type handlers), Dapper.Rainbow (CRUD helpers), and Dapper.SqlBuilder (dynamic fragment composition), plus Dapper.StrongName (signed build). The out-of-repo Dapper.Contrib and the commercial Dapper Plus add active-record-style CRUD and bulk operations on top.


Strengths

  • Minimal, near-zero abstraction cost. Extension methods on a connection you already own; no context object, no session, no configuration graph — add the package and call Query<T>.
  • Fast. An IL-emitted, per-shape, cached materializer and parameter generator put mapping within microseconds of hand-written SqlCommand on the project's own benchmarks — well ahead of EF Core/NHibernate.
  • Injection-safe by default. Parameter values become real ADO.NET DbParameters and are never spliced into SQL text; the only value-into-text path ({=…}) is bool/numeric-only.
  • Provider-agnostic. Works over any ADO.NET backend with no dialect layer, because it generates no SQL.
  • You keep full SQL control. Any query the database supports — CTEs, window functions, hints, vendor extensions — runs verbatim; nothing is hidden behind a query builder.
  • Rich mapping for a micro-ORM. Multi-mapping (splitOn), multiple result grids (QueryMultiple), list expansion, stored procs with output params, custom ITypeHandlers, and per-row type switching (GetRowParser).
  • Sync and async across the whole surface, with buffered/unbuffered and IAsyncEnumerable streaming.

Weaknesses

  • No compile-time SQL checking. SQL is opaque text; a bad column name, type mismatch, or renamed property is discovered at runtime, not by the compiler — the price of the raw-string model (no sqlx/sqlc-style build-time verification, no typed builder).
  • You own all the SQL and the schema. No query generation, no dialect portability, no migrations, no code generation — Dapper does nothing above map+bind.
  • No change tracking, identity map, or unit of work. Updates mean writing the UPDATE yourself; there is no dirty-state detection or SaveChanges. (A feature for control, a cost for CRUD-heavy apps — where EF Core earns its keep.)
  • No transaction abstraction. Transactions, savepoints, isolation, and retry are the raw ADO.NET IDbTransaction threaded through calls; nothing composes them.
  • Exception-based errors. No typed/value error channel and no retryability metadata; failure handling is try/catch on provider exceptions.
  • Reflection.Emit at the core. The IL materializer needs a JIT — a friction point for fully AOT/trimmed or reflection-restricted targets — and the query cache can grow under on-the-fly unparameterized SQL.
  • Mapping is by-name convention. Column↔member matching is stringly-typed and case-folded; the only knob is underscore matching. No declared, checkable mapping.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Extension methods on IDbConnection, not a context/session typeZero setup; drops into any existing ADO.NET code; the caller owns the connection & poolNo place to hang lifecycle/config; pooling, open/close, and transactions stay the caller's problem
Raw SQL you write, no query generation or builder in coreFull SQL power; provider-agnostic (no dialect layer); trivially portable snippetsNo compile-time column/type checking; SQL portability and correctness are on you
Parameters via plain-object properties → real DbParametersInjection-safe by default; ergonomic (anonymous types); no bind ceremonyBinding is by-name and reflective; ValueTuples can't be parameters (no member names)
List expansion + {=…} literals as the only SQL rewritesErgonomic IN clauses and plan-friendly literals without leaving the safe pathRegex rewriting of your text; {=…} reaches SQL (bool/numeric only, so still injection-safe)
IL-emitted, cached per-(type, columns) materializerMapping speed near hand-written ADO.NET; cost amortized across rows and repeat queriesNeeds Reflection.Emit/JIT (AOT/trimming friction); cache can bloat under generated unparameterized SQL
No schema / migrations / change tracking / identity mapStays a mapper, not an ORM — "the 95% scenario"; predictable, no hidden queries or flushesYou hand-write all DML/DDL and updates; no dirty tracking, no SaveChanges, no relation navigation
Transactions = ADO.NET IDbTransaction pass-throughNothing to learn beyond ADO.NET; provider owns nesting/savepoints/isolationNo composable transaction combinator, savepoints, retry, or isolation helper in the library
Blocking + Task async, eager (no effect value)Familiar imperative model; matches mainstream .NET; async everywhereNot an inspectable/deferred effect; no environment/error tracked in the type
Exception-based error handlingIdiomatic .NET; provider exceptions carry SQLSTATE/error codesNo typed/value error channel, no isRetryable; failure handling is try/catch

Sources