BrandGhost
EF Core vs Dapper in .NET: When to Use Each

EF Core vs Dapper in .NET: When to Use Each

EF Core vs Dapper in .NET: When to Use Each

Picking a data access library is one of those decisions that quietly shapes everything that follows. The ef core vs dapper debate comes up on almost every .NET project, and both sides have passionate advocates. Here's the reality: neither tool is universally better. They solve different problems at different levels of abstraction, and knowing which problem you actually have is how you pick the right one.

This article is for intermediate .NET developers who need to make that choice -- or who are questioning whether the choice they already made is still the right one. We'll compare both tools across the dimensions that actually matter, build up a decision matrix, and look at how to use them together when that makes sense.


What Is EF Core?

Entity Framework Core is a full object-relational mapper (ORM) built by Microsoft and maintained as part of the .NET ecosystem. It sits between your C# objects and a relational database and does a lot of heavy lifting for you.

The main features that define EF Core:

  • Code-first modeling -- define your schema in C# classes and let EF generate the database
  • Migrations -- version your schema changes alongside your application code
  • LINQ translation -- write queries in C# and EF translates them to SQL
  • Change tracking -- track which entities have been modified and compute the right UPDATE statements
  • Scaffolding -- generate entity classes and a DbContext from an existing database

EF Core is designed for developer productivity. You spend less time thinking about SQL and more time thinking about your domain model. The tradeoff is that EF Core has a learning curve -- DbContext lifetimes, navigation properties, change tracking semantics, and LINQ translation quirks all have to be understood before you can use it confidently.

In EF Core 10, improvements include production-ready pre-compiled queries (AOT), continued LINQ translation improvements, and SQLite date/time handling. JSON column support (since EF Core 7, refined in EF Core 8) and complex types (EF Core 8) remain key features.


What Is Dapper?

Dapper is a micro-ORM originally created by Sam Saffron and Marc Gravell of Stack Overflow and now maintained as an open-source project. It does exactly one thing: take SQL query results and map them to C# objects. That's it.

There's no LINQ translation, no migrations, no change tracking, no code generation. You write SQL. Dapper runs it. You get objects back. The entire library is a thin extension on top of IDbConnection.

That simplicity is the point. Dapper has almost no overhead on top of raw ADO.NET. If you already know SQL well, the learning curve is essentially zero -- you write the same queries you would have written by hand, and Dapper handles the tedious parameter binding and result mapping.

For .NET 10, Dapper targets the same async ADO.NET APIs that have been stable for years. Nothing changes fundamentally -- it just keeps working.


Side-by-Side Comparison

Abstraction Level

EF Core operates at a high abstraction level. You describe what data you want in C#, and EF generates the SQL. Dapper operates at a low abstraction level. You write the SQL, and Dapper handles the mapping.

Neither approach is wrong. High abstraction means more productive development for standard CRUD scenarios. Low abstraction means you're always in control of what's being sent to the database.

Performance

Dapper is faster for raw query execution. Because it does no change tracking and has minimal overhead over ADO.NET, it adds almost nothing to your query latency.

EF Core can close that gap significantly. Use AsNoTracking() for read-only queries and compiled queries for hot paths. With those optimizations, EF Core performance is competitive for most workloads -- often within a few percent of Dapper.

The important thing: benchmark your actual workload with BenchmarkDotNet. Raw numbers from synthetic microbenchmarks are rarely representative of your specific query mix, connection pool configuration, and hardware. Profile before optimizing.

Query Control

Dapper gives you 100% SQL control. You write exactly the query you want, including window functions, CTEs, hints, and database-specific syntax.

EF Core covers roughly 90% of queries well through LINQ. For the remaining 10%, FromSqlRaw lets you drop down to raw SQL while still benefiting from EF's result mapping and DbContext integration. For operations that don't return entities, ExecuteSqlRawAsync handles arbitrary SQL execution.

If you're working with complex reporting queries, stored procedures, or heavily optimized SQL, Dapper's "you write the SQL" model is often the pragmatic choice.

Migrations

EF Core has first-class migration support. dotnet ef migrations add, dotnet ef database update -- schema changes are versioned, tracked, and applied in a repeatable way. This is a big deal for team development and CI/CD pipelines.

Dapper has no migration tooling. You manage your schema separately. Common choices are FluentMigrator, DbUp, or Liquibase. None of these are as tightly integrated as EF Core migrations, but they work well if you already prefer SQL-based schema management.

Learning Curve

Dapper's learning curve is minimal if you know SQL. It's basically: open a connection, call QueryAsync, pass your SQL and parameters, get objects back.

EF Core has more concepts to internalize. You need to understand DbContext lifetimes (especially in ASP.NET Core DI), how change tracking works and when it costs you, how LINQ queries get translated (and when they don't), and how migrations interact with your model. None of this is insurmountable, but it takes time to build a correct mental model.

Team Familiarity

This one matters more than people admit. An EF Core-first team will be slower and more error-prone with Dapper if the project requires a lot of custom SQL. A SQL-heavy team that thinks in terms of queries and result sets will often find EF Core's abstractions feel like they're getting in the way.

Match the tool to your team, not just to the problem.

Schema Complexity

EF Core shines when your schema has complex relationships: many-to-many with payload, table-per-hierarchy or table-per-type inheritance, owned entity types, value objects, and temporal tables. Modeling these in EF Core is ergonomic because the ORM's configuration system was designed for exactly these cases.

Dapper can handle complex schemas too, but the mapping has to be done manually. Multi-result queries, split queries, and complex joins require explicit handling in your data access code.


Feature Comparison Table

Feature EF Core Dapper
SQL Generation Automatic (LINQ to SQL) Manual
Performance (raw) Good (excellent with AsNoTracking + compiled queries) Excellent
Migrations Built-in Manual (FluentMigrator, DbUp, etc.)
Learning Curve Moderate Low
Query Control High (FromSqlRaw for edge cases) Complete
Change Tracking Yes No
Schema Complexity Excellent (owned types, inheritance, etc.) Limited

Code Comparison: The Same Query Two Ways

Let's make this concrete. Here's a query that fetches a list of published blog posts with their authors, filtered by tag -- first in EF Core, then in Dapper.

EF Core: LINQ with Include and AsNoTracking

// EF Core -- read-only query with eager loading
// AsNoTracking skips change tracking overhead for read-only scenarios

public sealed class BlogPostRepository(AppDbContext db)
{
    public async Task<IReadOnlyList<BlogPostSummary>> GetPublishedByTagAsync(
        string tag,
        CancellationToken ct = default)
    {
        return await db.BlogPosts
            .AsNoTracking()
            // No Include() needed -- EF Core automatically generates the JOIN when Select() accesses p.Author
            .Where(p => p.IsPublished && p.Tags.Contains(tag))
            .OrderByDescending(p => p.PublishedAt)
            .Select(p => new BlogPostSummary(
                p.Id,
                p.Title,
                p.Author.DisplayName,
                p.PublishedAt))
            .ToListAsync(ct);
    }
}

public sealed record BlogPostSummary(
    int Id,
    string Title,
    string AuthorName,
    DateTimeOffset PublishedAt);

For deeper guidance on the LINQ operators used here -- Where, Select, OrderByDescending -- see the LINQ in C# Complete Guide and the LINQ Filtering in C# article for filtering specifics.

Dapper: The Same Query with SQL

// Dapper -- same result, you control the SQL
// Requires an IDbConnection (SqlConnection, NpgsqlConnection, etc.)

public sealed class BlogPostRepository(IDbConnection db)
{
    public async Task<IReadOnlyList<BlogPostSummary>> GetPublishedByTagAsync(
        string tag,
        CancellationToken ct = default)
    {
        const string sql = """
            SELECT
                p.Id,
                p.Title,
                a.DisplayName AS AuthorName,
                p.PublishedAt
            FROM BlogPosts p
            INNER JOIN Authors a ON a.Id = p.AuthorId
            WHERE p.IsPublished = 1
              AND p.Tags LIKE @TagPattern
            ORDER BY p.PublishedAt DESC
            """;

        var results = await db.QueryAsync<BlogPostSummary>(
            new CommandDefinition(sql, new { TagPattern = $"%{tag}%" }, cancellationToken: ct));

        return results.AsList();
    }
}

Both do the same job. EF Core's version is more refactor-safe (renaming a C# property won't silently break the query), while Dapper's version lets you write exactly the SQL you want with no surprises about what gets sent to the database.

Note: These examples use a simplified Tags representation. In EF Core, Tags.Contains(tag) works with a primitive collection (EF Core 8+) or navigation property. In Dapper, LIKE treats Tags as a delimited text column. In a real application, the data model for Tags would need to be consistent between both approaches.


EF Core Write: Add and SaveChangesAsync

// EF Core -- adding a new entity and persisting it
// Change tracking detects the new entity and generates INSERT SQL

public sealed class AuthorService(AppDbContext db)
{
    public async Task<int> CreateAuthorAsync(
        string displayName,
        string email,
        CancellationToken ct = default)
    {
        var author = new Author
        {
            DisplayName = displayName,
            Email = email,
            CreatedAt = DateTimeOffset.UtcNow,
        };

        db.Authors.Add(author);
        await db.SaveChangesAsync(ct);

        return author.Id; // EF populates the generated PK after save
    }
}

Dapper Write: ExecuteAsync with INSERT SQL

// Dapper -- explicit INSERT statement
// You control the SQL; Dapper handles parameter binding

public sealed class AuthorService(IDbConnection db)
{
    public async Task<int> CreateAuthorAsync(
        string displayName,
        string email,
        CancellationToken ct = default)
    {
        const string sql = """
            INSERT INTO Authors (DisplayName, Email, CreatedAt)
            OUTPUT INSERTED.Id
            VALUES (@DisplayName, @Email, @CreatedAt)
            """;

        return await db.ExecuteScalarAsync<int>(sql, new
        {
            DisplayName = displayName,
            Email = email,
            CreatedAt = DateTimeOffset.UtcNow,
        });
    }
}

The Hybrid Approach: Using Both in One Project

This is probably the most pragmatic answer for mature .NET applications. You don't have to pick one and stick with it everywhere.

A common pattern: use EF Core for all writes and complex domain operations (where change tracking and migrations pay for themselves), and use Dapper for read projections and reporting queries (where raw performance and SQL control matter more).

// Hybrid approach -- EF Core DbContext and Dapper share the same connection
// This keeps a single transaction boundary and avoids duplicate connection overhead

public sealed class OrderService(AppDbContext db)
{
    // EF Core handles the domain write -- full change tracking, validation, events
    public async Task PlaceOrderAsync(PlaceOrderRequest request, CancellationToken ct = default)
    {
        var order = new Order
        {
            CustomerId = request.CustomerId,
            PlacedAt = DateTimeOffset.UtcNow,
            Status = OrderStatus.Pending,
            Lines = request.Lines.Select(l => new OrderLine
            {
                ProductId = l.ProductId,
                Quantity = l.Quantity,
                UnitPrice = l.UnitPrice,
            }).ToList(),
        };

        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);
    }

    // Dapper uses the same underlying connection for a flat read projection
    // No change tracking overhead, full SQL control for the reporting query
    public async Task<IReadOnlyList<OrderSummaryDto>> GetCustomerOrderSummaryAsync(
        int customerId,
        CancellationToken ct = default)
    {
        var connection = db.Database.GetDbConnection();

        const string sql = """
            SELECT
                o.Id,
                o.PlacedAt,
                o.Status,
                SUM(l.Quantity * l.UnitPrice) AS TotalAmount,
                COUNT(l.Id) AS LineCount
            FROM Orders o
            INNER JOIN OrderLines l ON l.OrderId = o.Id
            WHERE o.CustomerId = @CustomerId
            GROUP BY o.Id, o.PlacedAt, o.Status
            ORDER BY o.PlacedAt DESC
            """;

        var results = await connection.QueryAsync<OrderSummaryDto>(sql, new { CustomerId = customerId });
        return results.AsList();
    }
}

public sealed record OrderSummaryDto(
    int Id,
    DateTimeOffset PlacedAt,
    string Status,
    decimal TotalAmount,
    int LineCount);

Using db.Database.GetDbConnection() lets Dapper reuse the same underlying database connection that EF Core is managing. If you're inside a transaction scope, Dapper can participate in it too via db.Database.CurrentTransaction?.GetDbTransaction().

This hybrid pattern is particularly useful in a modular monolith architecture, where each module might have different data access requirements -- some CRUD-heavy, some reporting-heavy.


Decision Matrix

Use this as a starting point, not a hard rule. Every project has context that changes the calculus.

Scenario Recommended Tool Reasoning
Standard CRUD application EF Core Developer productivity, migrations, change tracking all justify the investment
Extreme performance required Dapper Minimal overhead, full SQL control
Simple scripts or ETL Dapper No need for a full ORM, SQL is clearer
Rapid development, convention-driven EF Core Scaffolding, migrations, and LINQ reduce boilerplate fast
Legacy database with stored procedures Dapper or Hybrid Stored proc results map naturally with Dapper
Complex domain model EF Core Owned types, inheritance, navigation properties are EF Core strengths
Read-heavy reporting queries Dapper Write your SQL, no change tracking cost
Greenfield app, team knows C# not SQL EF Core Reduces context switching, LINQ covers most cases

A Note on Performance Numbers

You'll see benchmarks online claiming Dapper is 2x faster, or that EF Core with compiled queries is only 5% slower. Take all of those numbers with skepticism unless they match your scenario.

Performance depends heavily on:

  • Query complexity -- a simple SELECT * WHERE Id = @Id will show maximum Dapper advantage. A complex aggregation query with joins is a different story.
  • Connection pooling configuration -- the connection open/close overhead can dwarf ORM overhead for short-lived queries.
  • Change tracking scope -- EF Core with AsNoTracking() on read paths removes most of the overhead gap.
  • Compiled queries -- EF Core compiled queries cache the LINQ translation and can bring hot-path performance very close to Dapper.

The right answer: use BenchmarkDotNet, write benchmarks against your actual database with your actual queries on hardware that approximates production. Microbenchmarks are interesting; real workload benchmarks are actionable.

If you're adding structured logging to track performance in production, see the Logging in .NET Complete Guide and Serilog in .NET for how to capture query timings without cluttering your codebase.


Choosing a Data Access Architecture

The tool you pick influences -- and is influenced by -- your broader architecture decisions. Thinking about how your data access layer is structured matters regardless of which ORM you choose.

For example, if you're applying the Facade design pattern to hide data access complexity behind a clean interface, the facade can front either EF Core or Dapper with no impact on callers. That flexibility makes EF-to-Dapper migrations (or the hybrid approach) much lower risk.

LINQ projection is relevant to EF Core query design too -- the LINQ Projection in C# article covers Select and SelectMany in depth, which you'll use often when projecting EF Core results to DTOs.


Conclusion

Here's the practical guidance:

Choose EF Core when you're building a standard .NET application where developer productivity matters, you want schema migrations handled for you, your team thinks in C# and objects more than SQL, and you're working with a complex domain model.

Choose Dapper when your team is SQL-fluent, you're working with a legacy database or heavy stored procedure usage, you need raw performance for reporting queries, or you're building simple data access scripts where a full ORM is overkill.

Use both when your application has distinct read and write patterns -- EF Core for the domain write side where change tracking and migrations pay dividends, Dapper for the read projection side where you want full SQL control and minimum overhead. This isn't a compromise; it's a deliberate architecture decision that many mature .NET applications use in production.

Neither tool is wrong. The wrong move is picking one tool dogmatically and forcing it into every scenario, including the ones where the other tool is clearly a better fit. Know what each tool does well, understand your project's specific constraints, and make the call with evidence.


FAQ

What is the main difference between EF Core and Dapper?

EF Core is a full ORM that generates SQL from C# LINQ queries, manages schema migrations, and tracks entity changes automatically. Dapper is a micro-ORM that maps SQL query results to C# objects -- you write the SQL yourself. EF Core offers higher abstraction and developer productivity; Dapper offers lower overhead and complete SQL control.

Is Dapper always faster than EF Core?

Not always. Dapper is faster in raw microbenchmarks because it has minimal overhead. But EF Core with AsNoTracking() and compiled queries can come very close to Dapper performance on most real-world workloads. The gap depends on query complexity, connection pooling, and how well EF Core's LINQ translation handles your specific query patterns. Always benchmark your actual workload with BenchmarkDotNet rather than relying on generic numbers.

Can I use EF Core and Dapper together in the same project?

Yes, and this is a common production pattern. EF Core handles writes and complex domain operations where change tracking and migrations are valuable. Dapper handles read projections and reporting queries where SQL control and minimal overhead matter more. You can share the same database connection between them using db.Database.GetDbConnection() from the EF Core DbContext.

Does Dapper support database migrations?

No. Dapper has no migration tooling. You manage schema changes separately using tools like FluentMigrator, DbUp, or Liquibase. This is fine if your team prefers SQL-based schema management, but it requires more discipline than EF Core's first-class migration support.

When should I prefer EF Core over Dapper?

Prefer EF Core when you're building a greenfield application with a complex domain model, you want schema versioning handled by the framework, your team is more comfortable with C# and LINQ than writing raw SQL, or you're doing rapid development where convention-driven code generation saves significant time.

When should I prefer Dapper over EF Core?

Prefer Dapper when your team is SQL-fluent and finds writing SQL faster and clearer than writing LINQ, you're working with a legacy database you don't control, you have complex reporting queries that need precise SQL control, or you're building scripts and lightweight tooling where a full ORM is unnecessary overhead.

Does EF Core work with stored procedures?

Yes. EF Core supports stored procedures via FromSqlRaw for queries that return entities and ExecuteSqlRawAsync for non-query operations. However, Dapper is often the more ergonomic choice for stored procedure-heavy databases -- the SQL-centric model maps more naturally to calling procs and mapping their result sets, especially when proc outputs don't align neatly with entity types.

Weekly Recap: Entity Framework Core, the Memento Pattern, and C# Design Patterns [Jun 2026]

This week digs into Entity Framework Core from setup through CRUD operations and schema migrations, plus a deep run on the Memento pattern and other C# design patterns like Interpreter and Mediator. New videos cover tackling tech debt, build vs buy decisions, and growing into senior and leadership roles.

Entity Framework Core in .NET: The Complete Guide

Learn Entity Framework Core in .NET 10 -- DbContext, migrations, LINQ queries, relationships, performance tips, and when to use EF Core vs Dapper in C#.

EF Core Performance Best Practices in .NET 10

Optimize EF Core performance in .NET 10 -- AsNoTracking, compiled queries, split queries, bulk operations, N+1 fixes, and logging slow queries with Serilog.

An error has occurred. This application may no longer respond until reloaded. Reload