Updated Published 6 min readBy Muhammad Shahid

Dapper vs EF Core: Architecture and Hybrid Patterns

Dapper vs EF Core in ASP.NET Core: performance realities, change tracking overhead, sharing transactions in hybrid architectures, and when to use each tool.

Part of EF Core

Quick answers

Is Dapper always faster than EF Core in real applications?
No. For simple queries with AsNoTracking() and DTO projections (.Select()), modern EF Core (.NET 8/9/10) achieves near-identical execution speed to Dapper. Over 95% of database latency in production comes from network roundtrips, missing SQL indexes, and N+1 query patterns—which affect both Dapper and EF Core identically.
Can I use EF Core and Dapper in the same ASP.NET Core project?
Yes. The hybrid CQRS pattern uses EF Core for transactional writes, migrations, and aggregate domain logic, while using Dapper for complex analytical queries, recursive CTEs, window functions, and bulk reporting.
How do I share a single database transaction between EF Core and Dapper?
Access the underlying DbConnection and DbTransaction from EF Core using dbContext.Database.GetDbConnection() and dbContext.Database.CurrentTransaction.GetDbTransaction(). Pass these into Dapper's QueryAsync or ExecuteAsync methods so both tools enlist in the same atomic unit of work.
When should I choose Dapper over EF Core?
Choose Dapper when: (1) you require raw SQL features that LINQ cannot express (Table-Valued Parameters, query hints, dynamic pivot queries, recursive CTEs), (2) you need to query legacy databases with unconventional schemas, or (3) you are building high-throughput read-only analytical microservices.

Dapper vs EF Core is not an "either-or" religious debate—it is an architectural choice between high-productivity object-relational mapping with unit-of-work tracking (EF Core) and low-level, high-control raw SQL micro-ORM execution (Dapper). Modern enterprise applications frequently combine both using a hybrid CQRS pattern.

Hybrid Architecture Pattern:
├── Writes / Commands (EF Core):
│   └── Change Tracking, Business Invariants, Optimistic Concurrency, Migrations
│
└── Reads / Queries (Dapper or EF Core Projections):
    ├── Simple Reads ──► EF Core with .AsNoTracking().Select(dto => ...)
    └── Complex Reads ──► Dapper with Raw SQL, Window Functions & CTEs

New to this → start with Feature Comparison. Hybrid transaction sharing → Sharing transactions between EF Core and Dapper. EF Core optimization → SQL performance guide. Interview prep → If an interviewer asks.

Real-world analogy

  • EF Core: A full-service general contractor who builds your entire house from blueprints. They manage the plumbing, electrical permits, and framing inspections (Migrations, ChangeTracker, SaveChanges).
  • Dapper: A specialized master stone mason hired to install a custom curved marble fireplace. You supply the exact cut specifications (raw SQL), and the mason sets the stone instantly without asking questions about the house's foundation.

You do not tear down the house or fire the contractor just because one fireplace required custom masonry.

Feature-by-feature comparison

Table: Feature, Entity Framework Core (EF Core 9+), Dapper
FeatureEntity Framework Core (EF Core 9+)Dapper
ORM CategoryFull Object-Relational Mapper (ORM)Micro-ORM / SQL Mapper
Mapping MechanismLINQ to SQL Expression Tree CompilationHigh-speed dynamic IL emission
Change TrackingFull Unit-of-Work & Dirty TrackingNone (Stateless execution)
Schema MigrationsCode-First Migration engine with snapshotsNone (Requires DbUp, FluentMigrator, or SQL scripts)
Batch UpdatesExecuteUpdateAsync() & ExecuteDeleteAsync()Raw SQL UPDATE ... WHERE ...
Complex SQL SupportBroad LINQ support; complex CTEs/hints require raw SQLComplete, unrestricted access to any SQL dialect feature
Memory OverheadLow with AsNoTracking(); Moderate with trackerExtremely low (close to raw SqlDataReader)

When EF Core is the right choice

  1. Transactional Aggregate Roots: Creating or updating complex domain entities with child collections, where EF Core manages foreign key associations, concurrency tokens (RowVersion), and validation in a single atomic SaveChangesAsync().
  2. Standard CRUD and DTO Projections: Modern EF Core compiles LINQ queries into lean SQL when using .AsNoTracking().Select(), generating identical SQL to hand-written Dapper queries without string concatenation risks.
  3. Database Migrations and CI/CD: Managing evolving schema migrations, seed data, and schema drift checks.
// High-performance EF Core DTO Projection (0 tracker allocation)
public async Task<List<OrderSummaryDto>> GetRecentOrdersAsync(
    AppDbContext db, 
    Guid customerId, 
    CancellationToken ct)
{
    return await db.Orders
        .AsNoTracking()
        .Where(o => o.CustomerId == customerId)
        .OrderByDescending(o => o.CreatedAt)
        .Select(o => new OrderSummaryDto(o.Id, o.TotalAmount, o.Status, o.CreatedAt))
        .Take(20)
        .ToListAsync(ct);
}

When Dapper is the right choice

  1. Table-Valued Parameters (TVPs): Passing bulk collections of thousands of records to SQL Server in a single roundtrip using structured types.
  2. Advanced SQL Features: Queries utilizing MERGE, recursive Common Table Expressions (WITH Recursive), dynamic PIVOT statements, or query hints (WITH (NOLOCK), OPTION (RECOMPILE)).
  3. Legacy Database Mapping: Databases with composite keys without primary constraints, non-standard naming conventions, or stored procedure-driven legacy architectures.
// Advanced Dapper Query with Window Functions
using System.Data;
using Dapper;
using Microsoft.Data.SqlClient;

public async Task<IEnumerable<MonthlyRevenueDto>> GetMonthlyRevenueReportAsync(
    string connectionString, 
    int year, 
    CancellationToken ct)
{
    const string sql = """
        WITH RankedSales AS (
            SELECT 
                MONTH(CreatedAt) AS [Month],
                TotalAmount,
                SUM(TotalAmount) OVER(PARTITION BY MONTH(CreatedAt)) AS MonthlyTotal,
                ROW_NUMBER() OVER(PARTITION BY MONTH(CreatedAt) ORDER BY TotalAmount DESC) AS [Rank]
            FROM Orders
            WHERE YEAR(CreatedAt) = @Year AND Status = 'Paid'
        )
        SELECT [Month], MonthlyTotal, TotalAmount AS TopSaleAmount
        FROM RankedSales
        WHERE [Rank] = 1
        ORDER BY [Month];
        """;

    using var connection = new SqlConnection(connectionString);
    var command = new CommandDefinition(sql, new { Year = year }, cancellationToken: ct);
    return await connection.QueryAsync<MonthlyRevenueDto>(command);
}

Sharing transactions between EF Core and Dapper

In a hybrid CQRS setup, execute Dapper queries inside an EF Core IDbContextTransaction:

public async Task ProcessOrderAndReportAsync(
    AppDbContext dbContext, 
    Order newOrder, 
    CancellationToken ct)
{
    // 1. Begin atomic EF Core transaction
    await using var transaction = await dbContext.Database.BeginTransactionAsync(ct);

    try
    {
        // 2. Perform write with EF Core change tracker
        dbContext.Orders.Add(newOrder);
        await dbContext.SaveChangesAsync(ct);

        // 3. Obtain underlying DbConnection & DbTransaction for Dapper
        var dbConnection = dbContext.Database.GetDbConnection();
        var currentDbTransaction = transaction.GetDbTransaction();

        // 4. Execute Dapper command within the same transaction!
        const string auditSql = """
            INSERT INTO AuditLogs (Id, Action, EntityId, Timestamp)
            VALUES (@Id, @Action, @EntityId, @Timestamp);
            """;

        await dbConnection.ExecuteAsync(auditSql, new
        {
            Id = Guid.NewGuid(),
            Action = "OrderCreated",
            EntityId = newOrder.Id.ToString(),
            Timestamp = DateTime.UtcNow
        }, transaction: currentDbTransaction);

        // 5. Commit both operations atomically
        await transaction.CommitAsync(ct);
    }
    catch
    {
        await transaction.RollbackAsync(ct);
        throw;
    }
}

Common mistakes and pitfalls

  • Rewriting EF Core in Dapper to fix slow queries without checking indexes: If a query is slow because it performs a full table scan on a 5-million-row table with no index, converting it to Dapper will still perform a full table scan. Check your SQL execution plan and indexes first.
  • Forgetting AsNoTracking() in EF Core read queries: Running read-only queries with full change tracking allocates unnecessary snapshot objects in memory. Always use AsNoTracking() or project to DTOs.
  • Maintaining two separate database migration engines: Using EF Core migrations for some tables and raw SQL scripts or Flyway for others causes schema drift and deployment chaos. Keep schema migrations unified in EF Core.
  • Ignoring SQL injection risks with dynamic Dapper queries: Never concatenate user input directly into Dapper SQL strings ("WHERE Name = '" + input + "'"). Always pass parameterized objects (new { Name = input }).

If an interviewer asks

30-second answer: EF Core is a full ORM offering change tracking, unit-of-work transactions, and schema migrations for complex domain models. Dapper is a lightweight micro-ORM optimized for fast raw SQL mapping without tracking overhead. In modern .NET architectures, we use EF Core for domain writes and transactional operations, and use Dapper (or EF Core untracked projections) for high-performance reporting and complex SQL queries.

Strong answer: The belief that "Dapper is always needed for performance" is outdated. In modern .NET 8/9/10, EF Core compiled LINQ projections (.AsNoTracking().Select()) achieve within 2-5% of Dapper's speed. In enterprise architectures, we adopt a pragmatic hybrid model: EF Core manages aggregate roots, domain invariants, optimistic concurrency (RowVersion), and migrations. We bring in Dapper specifically when queries require SQL Server Table-Valued Parameters, recursive CTEs, or window functions, enlisting Dapper directly into EF Core's ambient DbTransaction to guarantee transactional consistency across writes.