Dapper — The Micro-ORM from Basics to Advanced
In short: A micro-ORM: extension methods on IDbConnection that execute SQL you write and map result rows to objects automatically.
- It's almost as fast as raw ADO.NET with far less code, gives full control over SQL (stored procedures, CTEs, window functions), and has no change tracking or migrations.
- Ideal for read-heavy and performance-critical data access and for teams comfortable with SQL.
In short: Dapper: you write SQL; minimal overhead; no change tracking, relationships, migrations or LINQ translation — perfect for reports and tuned queries.
- EF Core: LINQ queries translated to SQL, change tracking and
SaveChanges, relationships, migrations, interceptors — faster to build CRUD and domain models, some overhead. - Many projects use both: EF Core for writes, Dapper for complex reads.
In short: When you pass values in the parameter object (new { Id = id }) or DynamicParameters, Dapper creates real DbParameters, so the database treats them as data, never as SQL.
- Injection is only possible if you concatenate user input into the SQL string — never do that; whitelist identifiers like sort columns.
In short: Query returns all rows (IEnumerable<T>).
QueryFirstreturns the first row and throws if none (OrDefaultreturns default).QuerySingleexpects exactly one row and throws on zero or multiple (OrDefaultallows zero).Executeruns a command and returns the number of affected rows;ExecuteScalarreturns the first column of the first row.
Create DynamicParameters, add inputs and add outputs with direction: ParameterDirection.Output (and ReturnValue if needed), call Query/Execute with commandType: CommandType.StoredProcedure, then read outputs with p.Get<T>("@Name") after the results have been consumed.
In short: Mapping one row from a JOIN into several related objects: Query<Order, Customer, Order>(sql, (o, c) => { o.Customer = c; return o; }, splitOn: "Id").
splitOntells Dapper at which column the next object's columns begin (default "Id").- For one-to-many, use a dictionary to de-duplicate parent objects and add children.
In short: Open the connection, BeginTransaction, pass the transaction to every Dapper call (transaction: tx or positional argument), Commit on success and Rollback in a catch (or let Dispose roll back).
- For multiple repositories in one operation, share the connection and transaction through a unit-of-work object.
TransactionScopealso works (with async flow enabled).
In short: Not by passing a list to Execute (one round trip per row).
- Use
SqlBulkCopyfor raw speed, a table-valued parameter to a stored procedure (orMERGE) for set-based logic, or JSON +OPENJSONin one statement. - Batch very large loads and use transactions appropriately.
In short: Buffered (default) reads all rows into a list before returning and closes the reader — simple and safe.
- Unbuffered (
buffered: falseorQueryUnbufferedAsync) yields rows while reading, keeping the reader and connection open until enumeration finishes — lower memory for huge results, but you must finish iterating before using the connection again.
In short: By name, case-insensitive, to public properties/fields or constructor parameters.
- For mismatches use SQL aliases (
SELECT product_name AS Name),DefaultTypeMap.MatchNamesWithUnderscores = true, or a custom type map (SqlMapper.SetTypeMapwithCustomPropertyTypeMap). - Custom value conversions use
SqlMapper.TypeHandler<T>.
In short: It generates and caches mapping code per (SQL, parameter type, result type) the first time a query runs — IL-emitted delegates that read the data reader directly into objects and write parameters — so later calls avoid reflection.
- It adds very little on top of ADO.NET: no change tracking, no expression translation.
- Dapper.AOT moves this generation to compile time with source generators.
In short: Register a connection factory (singleton) that creates new SqlConnections;
- create, use and dispose a connection per operation (
await using), relying on connection pooling. - For multi-step transactions use a scoped unit of work holding one connection and transaction.
- Avoid keeping connections open for the whole request or sharing one connection across threads.
In short: Different parameter types: Dapper sends string as NVARCHAR, and if the column is VARCHAR SQL Server converts the column, preventing an index seek — use DbString { IsAnsi = true }.
- Parameter sniffing: SSMS runs with literal values while the app reuses a plan compiled for different values — check the cached plan;
- consider
OPTION (RECOMPILE)or better indexes. - Also different SET options (ARITHABORT) produce different plans, and the app may be returning far more rows/columns or doing N+1 calls.
In short: SQL injection risk, no plan reuse in SQL Server and an ever-growing Dapper query cache (each distinct SQL string is cached) → memory growth.
- Fix: parameterise all values, build optional filters with
WHERE (@x IS NULL OR Col = @x)or a safe builder (DapperQueryBuilder /SqlBuilder) that appends parameterised fragments, and whitelist dynamic identifiers.
In short: Keep the SQL and stored procedures;
- replace manual reader loops with
Query<T>mapped to DTOs,DynamicParametersfor procedures with outputs, andQueryMultiplefor multi-result procedures. - Do it repository by repository behind existing interfaces, with integration tests comparing outputs before and after.
- Watch column-name mismatches and type differences (DBNull handling, decimal/money, dates).