“EF Core or Dapper?” is usually the wrong question. The right question is which parts of your data layer benefit from change tracking and migrations, and which parts just need to run a query and map rows as fast as possible. Most of our .NET 10 services end up using both, on purpose.

Key takeaways
  • EF Core earns its keep on the write side: change tracking, migrations, and LINQ make CRUD-heavy, transactional code fast to write and safe to evolve.
  • Dapper earns its keep on the read side: reporting queries, dashboards, and high-volume list endpoints skip tracking overhead entirely.
  • EF Core 10 (paired with .NET 10) adds faster ExecuteUpdate/ExecuteDelete bulk operations and better JSON column support, closing some of the historical performance gap.
  • You can share one connection between EF Core and Dapper in the same request — no need to pick one for the whole application.

What EF Core is actually good at

Change tracking means you load an entity, mutate it, and call SaveChanges — EF Core figures out the SQL. Migrations keep your schema and your model in version control together. LINQ gives you compile-time-checked queries. For the transactional core of a domain — placing an order, updating a profile, anything where you're loading an aggregate, changing it, and persisting it as a unit — this is a real productivity win, not just convenience.

csharp
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
}

public async Task SubmitOrderAsync(Guid orderId, CancellationToken ct)
{
    var order = await _db.Orders
        .Include(o => o.Lines)
        .FirstOrDefaultAsync(o => o.Id == orderId, ct)
        ?? throw new NotFoundException(nameof(Order), orderId);

    order.Submit();
    await _db.SaveChangesAsync(ct);
}

.NET 10 ships alongside EF Core 10, which pushes more work down to the database instead of materializing entities first. ExecuteUpdateAsync and ExecuteDeleteAsync issue a single SQL statement for bulk operations without loading rows into the change tracker at all — useful for the kind of housekeeping query that has nothing to do with your domain model.

csharp
// Bulk update, no entities loaded, no change tracking overhead
await _db.Orders
    .Where(o => o.Status == OrderStatus.Draft && o.CreatedAt < cutoff)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(o => o.Status, OrderStatus.Expired), ct);

What Dapper is actually good at

Dapper maps query results onto plain objects with (close to) the performance of raw ADO.NET, because it doesn't track anything. For read-heavy endpoints — list views, dashboards, exports, anything where you're pulling hundreds or thousands of rows and immediately serializing them — skipping the change tracker is a meaningful, measurable win, and writing the SQL by hand gives you full control over exactly what gets fetched.

csharp
public async Task<IReadOnlyList<OrderSummary>> GetRecentOrdersAsync(
    Guid customerId, CancellationToken ct)
{
    const string sql = @"
        SELECT o.id AS OrderId, o.total AS Total, o.status AS Status, o.created_at AS CreatedAt
        FROM orders o
        WHERE o.customer_id = @CustomerId
        ORDER BY o.created_at DESC
        LIMIT 50
        ";

    var connection = _db.Database.GetDbConnection();
    var results = await connection.QueryAsync<OrderSummary>(
        new CommandDefinition(sql, new { CustomerId = customerId }, cancellationToken: ct));

    return results.AsList();
}

Sharing one connection between both

You don't need two separate data access stacks. Pulling the underlying DbConnection off your DbContext lets Dapper and EF Core participate in the same connection — and the same transaction, if you're inside one.

csharp
public class OrderReadRepository(AppDbContext db) : IOrderReadRepository
{
    public Task<IEnumerable<OrderSummary>> SearchAsync(OrderSearchQuery query, CancellationToken ct)
    {
        var connection = db.Database.GetDbConnection();
        // connection is opened/managed by EF Core; Dapper just borrows it
        return connection.QueryAsync<OrderSummary>(BuildSql(query), query, ct);
    }
}
TransactionsIf EF Core opened the connection as part of an active transaction (db.Database.BeginTransactionAsync()), Dapper queries on that same connection participate in it automatically. You don't need a separate TransactionScope.

A simple rule of thumb

Command side (anything that changes state, especially multi-step domain logic): EF Core. Query side (anything that just reads and shapes data for a response): Dapper, once the LINQ query starts fighting you or the profiler shows tracking overhead. Below that threshold, EF Core's AsNoTracking() queries are simpler and usually fast enough — don't reach for Dapper until you've measured a reason to.

Side by side

EF CoreDapper
Change trackingBuilt inNone (by design)
MigrationsBuilt inBring your own
Query languageLINQRaw SQL
Best forTransactional writes, aggregatesReads, reporting, high-volume lists
Raw SQL controlPossible, awkwardNative

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.