AlgoMaster Logo

Dapper (Micro-ORM)

Medium Priority18 min readUpdated June 6, 2026

Entity Framework Core is the heavyweight ORM in the .NET world: it tracks entity state, generates SQL, runs migrations, and lets you write LINQ instead of strings. That power comes with a real cost in indirection, allocations, and surprising query plans. Dapper sits at the other end of the spectrum. It's a tiny library that takes raw SQL you write and maps the result columns onto C# objects, and that's almost all it does. This lesson covers what Dapper is, how it compares to EF Core, the core APIs (Query, Execute, multi-mapping, QueryMultiple), and when to pick it.

What a Micro-ORM Is

A full ORM like EF Core takes responsibility for a lot: it knows your model, generates SQL from LINQ, tracks which entities have changed, handles relationships, runs migrations, and translates database errors into framework exceptions. A micro-ORM does one job: it maps rows from a result set to objects. Everything else, including writing the SQL, opening the connection, managing transactions, and handling schema changes, stays in your hands.

Dapper is the most popular micro-ORM in the .NET ecosystem. Under the hood, it's a set of extension methods on `IDbConnection`, the ADO.NET interface every database driver in .NET implements. There's no DbContext, no entity registration, no model builder, no change tracker. You hand Dapper a SQL string and a parameter object, and it hands you back a list of objects. The library that runs Stack Overflow built it, which is part of why it has a reputation for being fast.

The diagram makes the difference visual. Dapper adds one thin layer on top of ADO.NET. EF Core adds several. Each EF Core layer is worth its overhead when you need what it provides (change tracking, LINQ, migrations), but if you don't, you're paying for indirection you don't use. That's the core trade-off this lesson keeps coming back to.

Dapper vs EF Core, Honestly

The two libraries solve overlapping problems but optimize for different things. Neither one is "better." They make different bets.

AspectEF CoreDapper
ProductivityHigh. LINQ, scaffolding, change tracking save typing for CRUD.Lower for CRUD. You write the SQL yourself.
Raw read perfGood after warm-up, slower on cold queries.Very close to hand-written ADO.NET. Usually fastest.
Control over SQLLINQ-to-SQL generator decides. You can override with raw SQL.Total. You wrote the SQL, that's what runs.
Learning curveSteeper. DbContext lifetime, tracking, migrations, conventions.Shallow. If you know SQL, you know most of Dapper.
Change trackingBuilt in. SaveChanges figures out inserts/updates/deletes.None. You write the UPDATE/DELETE yourself.
MigrationsFirst class via dotnet ef migrations.None. Use FluentMigrator, DbUp, or EF Core migrations alongside.
RelationshipsModeled in entity classes, navigated as properties.Manual via multi-mapping or QueryMultiple.
Best fitDomain-heavy CRUD apps, models that match the schema closely.Read-heavy paths, complex reporting SQL, hot-path queries.

The thing most teams miss the first time is that you don't have to pick one. A common production pattern is EF Core for writes and CRUD endpoints, Dapper for read-heavy endpoints and reporting, both pointing at the same database. We'll come back to that pattern near the end.

Installing Dapper and a Database Provider

Dapper itself is one NuGet package, and you also need an ADO.NET provider for whatever database you're targeting. The provider is what actually talks to the database, and Dapper is what maps the rows.

For the examples in this lesson we'll use SQLite because it needs no server. The same code works against SQL Server or PostgreSQL by swapping the connection type and tweaking the SQL dialect.

Other common pairings look like this.

DatabaseProvider packageConnection type
SQLiteMicrosoft.Data.SqliteSqliteConnection
SQL ServerMicrosoft.Data.SqlClientSqlConnection
PostgreSQLNpgsqlNpgsqlConnection
MySQLMySqlConnectorMySqlConnection

Once installed, the entry point on every database is the same shape: open a connection that implements IDbConnection, and call Dapper extension methods on it. Here's a connection setup we'll reuse for the rest of the lesson.

A few things worth pointing out. The using statement on the connection makes sure it's disposed even if an exception is thrown later, which is the same pattern you use with any IDisposable. OpenAsync is the recommended way to open the connection in async code, but Dapper will open it for you automatically if you forget, and close it at the end of the call. Calling OpenAsync once and reusing the open connection is faster than letting Dapper open and close it per call, especially if there are many calls.

The ExecuteAsync call here runs the DDL. It's the same method whether you're creating a table, inserting a row, or running a stored procedure that returns no rows.

Querying Rows with Query and QueryAsync

The most common Dapper call is Query<T> (or its async cousin QueryAsync<T>). It takes a SQL string, optionally a parameters object, and returns an IEnumerable<T> of materialized objects.

Dapper looks at the column names in the result set and matches them against the constructor parameters or properties of Product. The match is case-insensitive. Records work especially well as Dapper targets because the positional constructor lines up with column order naturally, but classes with properties work too.

The async version returns Task<IEnumerable<T>>, but the buffered default means Dapper reads every row off the wire before handing you the result. If you only want one row, you're paying to materialize all of them.

There are a few siblings of Query, each with slightly different semantics.

MethodReturnsThrows if zero rowsThrows if more than one row
Query<T>IEnumerable<T> (zero or many)NoNo
QueryFirst<T>First rowYes (InvalidOperationException)No, ignores rest
QueryFirstOrDefault<T>First row or default(T)NoNo, ignores rest
QuerySingle<T>The one rowYesYes
QuerySingleOrDefault<T>The row or default(T)NoYes

The "Single" variants are the right pick when your query has a unique key in the WHERE clause and you want a loud failure if the database is in an unexpected state. The "First" variants are the right pick when you're sorting and grabbing the top result. Picking the right one is a small thing that catches real bugs.

The anonymous object new { Id = 1 } is how you pass parameters, which is the next thing to cover.

Parameters: Named, Lists, and IN Clauses

Parameters in Dapper use named parameters (@Name, @Id, @Email) in the SQL string, and Dapper binds them from a plain object. The shape Dapper looks for is just "a member whose name matches the parameter name." Anonymous objects, records, classes, and dictionaries all work.

The important thing this gives you, and the reason you should always use parameters rather than string interpolation, is protection from SQL injection. The value never gets pasted into the SQL string; the driver sends it separately. Concatenating user input into SQL with $"... WHERE Name = '{userInput}'" is the classic security mistake. Don't do it. Bind parameters every time.

For an IN clause, Dapper has a feature most ORMs make you fight: pass a collection, and Dapper expands it into the right number of parameters automatically.

The SQL is WHERE Id IN @Ids, with no parentheses around @Ids. Dapper rewrites this to WHERE Id IN (@Ids1, @Ids2, @Ids3) before sending it to the database, binding each value to a separate parameter. This avoids the SQL injection trap and works across providers.

Inserts, Updates, and Deletes with Execute

Anything that doesn't return rows uses Execute (or ExecuteAsync). The return value is the number of rows affected, which lets you sanity-check what happened.

Execute is also the call you use for CREATE TABLE, DROP TABLE, and any other statement that returns no result set. The inserted, updated, and deleted counts give you a cheap sanity check: a missing WHERE clause that nukes more rows than expected jumps out immediately when you log the count.

You can run the same statement many times with different parameters by passing a collection. Dapper iterates and runs the statement once per element, which is how you do batch inserts.

For three rows on a local SQLite, this is fine. For thousands of rows on a remote SQL Server, you'd want a bulk-insert API specific to your provider (SqlBulkCopy for SQL Server, BinaryImporter for Npgsql). Dapper doesn't do bulk operations natively; it just loops.

Multi-Mapping for Joins

Joins are where micro-ORMs get interesting, because there's no entity graph for Dapper to walk. You write the join in SQL, and you tell Dapper how to split each row across multiple objects. This is multi-mapping.

The signature is Query<TFirst, TSecond, TReturn>(sql, mapper, parameters, splitOn: "ColumnName"). The splitOn is the column where the second object's columns start in the result set. Dapper builds a TFirst, then sees the splitOn column, then starts a TSecond, then calls your mapper to combine them.

The splitOn: "Id" tells Dapper "after you've read Order.Quantity, the next column called Id is where Customer begins." This is why the column order in the SELECT matters: all the Order columns must come before the first Customer column.

You can extend this to three tables by adding more type parameters: Query<TFirst, TSecond, TThird, TReturn>(...) with a comma-separated splitOn: "Id,Id". The pattern continues up to seven types.

One often-overlooked detail: you don't have to use anonymous-style column names. If you alias the split column distinctively, the splitOn becomes self-documenting: SELECT ..., c.Id AS CustomerId, ... and splitOn: "CustomerId" is clearer than relying on the second Id column.

Batched Result Sets with QueryMultiple

Sometimes you want to send a single round trip to the database and read back several independent result sets. The classic example is a "dashboard" endpoint that needs counts plus a list of recent items. Doing this as three separate queries means three round trips. Doing it with QueryMultiple means one.

The order you call Read and ReadSingle must match the order of the statements in the SQL. The using on the multi grid is important because it holds the underlying reader open until you've drained all the result sets.

The win here is one round trip instead of four. On a database 10 ms away over the network, that's 30 ms saved per request, which adds up fast on a busy endpoint.

Don't use QueryMultiple just because you can. If the four queries don't logically belong together, keeping them separate is more maintainable, and most modern apps run them on a pooled connection where the round-trip cost is small. Use QueryMultiple when the queries genuinely serve one screen or one page, not as a default.

Transactions

Dapper doesn't invent its own transaction abstraction. It uses IDbTransaction from ADO.NET, the same one any provider gives you. The pattern is: begin a transaction on the connection, pass it to every Dapper call you want enrolled, commit or roll back at the end.

The pattern is identical to plain ADO.NET because that's what Dapper is sitting on top of. The using on tx ensures dispose runs even if you forget to commit or roll back, and Dispose on a non-committed transaction rolls back automatically. The transaction: tx argument on each ExecuteAsync is the part that's easy to miss: if you skip it, the statement runs outside the transaction, which silently breaks atomicity.

The "decrement stock conditionally and check the rows-affected" trick on the UPDATE is a tiny safety net. If two orders race for the last unit, only one of them sees rowsUpdated == 1, and the other rolls back cleanly. The database does the work; you just have to ask the right question.

Dapper.Contrib and Friends

A small ecosystem of helper packages sits on top of Dapper for the cases where typing the SQL gets repetitive. The most common is Dapper.Contrib, which adds basic CRUD: Get<T>, Insert, Update, Delete, all driven by attributes on the entity class.

A few related packages do similar things: Dapper.SimpleCRUD, Dapper.FluentMap, Dapper.FastCRUD. Be honest about what you're getting: a tiny slice of EF Core's CRUD productivity, without change tracking or migrations, and with another dependency to maintain. For many teams, plain Dapper plus a handful of well-named query methods is clearer than using a CRUD helper.

If you do go this way, keep the boundary clean: use Contrib for the dumb CRUD paths and write hand-tuned SQL with plain Dapper for the queries that matter.

When to Pick Dapper, When to Pick EF Core, and When to Use Both

Picking a data access layer is one of those choices that depends on what the code is for, not on which library is "better." Here are the cases where the answer leans clearly one way.

Use Dapper when:

  • The endpoint is read-heavy and the query is specific (reporting endpoints, dashboards, search results with custom ranking).
  • You already have hand-tuned SQL from a DBA or from EXPLAIN-driven optimization, and you don't want an ORM rewriting it.
  • The hot path is so hot that even small per-row allocations matter (think tight inner loops in a service that runs 10k requests per second).
  • You're working with a database schema that doesn't fit cleanly into entities (heavy use of views, stored procedures, dynamic SQL).
  • The team strongly prefers writing SQL and finds LINQ-to-SQL translation hard to reason about.

Use EF Core when:

  • The work is CRUD-shaped, the model maps naturally to the schema, and the SQL would be repetitive boilerplate.
  • You want migrations as a first-class part of the workflow.
  • You want change tracking: load an entity, mutate it, call SaveChanges, let the framework figure out the UPDATE.
  • Your team is more comfortable with LINQ than with hand-written SQL.

Use both in one app when:

  • Writes and CRUD endpoints go through EF Core, so migrations, validation, and change tracking stay clean.
  • Read-heavy endpoints, reports, and complex joins go through Dapper, sharing the same connection string and the same database.

The "both" pattern is more common than newcomers expect. There's no contradiction: EF Core and Dapper both speak ADO.NET, and they can even share the same DbConnection in advanced scenarios. You're not pledging allegiance to a framework; you're picking the appropriate tool per code path.

This is the kind of query where Dapper shines: a tailored SELECT with a LEFT JOIN, a GROUP BY, and a parameterised LIMIT, returning a flat shape designed for the API response. Writing it in LINQ-to-Entities is possible but the generated SQL is rarely what you'd write by hand, and the materialiser would build entities you immediately throw away. Dapper builds exactly what you asked for, nothing more.

One thing Dapper does not do is migrations. There's no dotnet dapper migrations add command because there's no model for Dapper to diff. If you want a versioned schema history, you pair Dapper with a dedicated migration tool. The common choices are FluentMigrator (C# migrations), DbUp (plain SQL scripts), or EF Core migrations (even if your runtime code uses Dapper, you can keep EF Core around just to manage the schema). Pick one early and stick with it; rebuilding migrations later is painful.

Quiz

Dapper Quiz

9 quizzes