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.
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.
The two libraries solve overlapping problems but optimize for different things. Neither one is "better." They make different bets.
| Aspect | EF Core | Dapper |
|---|---|---|
| Productivity | High. LINQ, scaffolding, change tracking save typing for CRUD. | Lower for CRUD. You write the SQL yourself. |
| Raw read perf | Good after warm-up, slower on cold queries. | Very close to hand-written ADO.NET. Usually fastest. |
| Control over SQL | LINQ-to-SQL generator decides. You can override with raw SQL. | Total. You wrote the SQL, that's what runs. |
| Learning curve | Steeper. DbContext lifetime, tracking, migrations, conventions. | Shallow. If you know SQL, you know most of Dapper. |
| Change tracking | Built in. SaveChanges figures out inserts/updates/deletes. | None. You write the UPDATE/DELETE yourself. |
| Migrations | First class via dotnet ef migrations. | None. Use FluentMigrator, DbUp, or EF Core migrations alongside. |
| Relationships | Modeled in entity classes, navigated as properties. | Manual via multi-mapping or QueryMultiple. |
| Best fit | Domain-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.
Cost note: EF Core's "slower" reputation is mostly about the materializer (the code that turns rows into objects) and query translation overhead on cold paths. For a single-row lookup by primary key on a warm connection pool, the difference is small. For a 50-column report that pulls 100k rows and joins three tables, Dapper can be several times faster simply because it does less work per row.
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.
| Database | Provider package | Connection type |
|---|---|---|
| SQLite | Microsoft.Data.Sqlite | SqliteConnection |
| SQL Server | Microsoft.Data.SqlClient | SqlConnection |
| PostgreSQL | Npgsql | NpgsqlConnection |
| MySQL | MySqlConnector | MySqlConnection |
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.
Query and QueryAsyncThe 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.
| Method | Returns | Throws if zero rows | Throws if more than one row |
|---|---|---|---|
Query<T> | IEnumerable<T> (zero or many) | No | No |
QueryFirst<T> | First row | Yes (InvalidOperationException) | No, ignores rest |
QueryFirstOrDefault<T> | First row or default(T) | No | No, ignores rest |
QuerySingle<T> | The one row | Yes | Yes |
QuerySingleOrDefault<T> | The row or default(T) | No | Yes |
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.
Cost note: Query<T> is buffered by default and materializes every row before returning. If you only need one row, use QueryFirst or QuerySingle so the SQL can use LIMIT 1 (or TOP 1) and Dapper allocates one object instead of a list. Materializing thousands of rows just to read .First() is a real cost.
IN ClausesParameters 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.
Perf callout: parameter sniffing. When you build SQL dynamically with IN clauses of widely different sizes (3 ids one call, 5000 the next), the database has to plan each unique query separately. The plan cache fills up with one-shot plans, and you lose the benefit of reuse. For very large lists, consider sending a table-valued parameter on SQL Server, or staging the ids in a temporary table on PostgreSQL. For small to moderate sizes, the IN @Ids expansion is fine.
ExecuteAnything 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.
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.
QueryMultipleSometimes 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.
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.
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.
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:
EXPLAIN-driven optimization, and you don't want an ORM rewriting it.Use EF Core when:
SaveChanges, let the framework figure out the UPDATE.Use both in one app when:
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.
9 quizzes