Data access in .NET: EF Core, Dapper and the rest

One page for the decision behind the requirement “EF Core, Dapper, or another ORM/data-access framework”: what each option actually does, where its cost sits, and how to combine them without turning the data layer into two competing truths.

Contents
  1. The four families of data access
  2. EF Core — a full O/RM
  3. What EF Core 10 changed
  4. Dapper — a micro-ORM
  5. Other frameworks: NHibernate, linq2db, Marten, RepoDB, ADO.NET
  6. Side-by-side comparison
  7. Patterns: repository, unit of work, CQRS split
  8. Performance: where each one hurts
  9. Transactions, concurrency and migrations
  10. Testing the data layer
  11. Decision guide and review checklist
  12. Sources

1. The four families of data access

“ORM or Dapper” is really a choice between four levels of abstraction. Each one trades control for convenience, and the trade is not symmetrical: what you give up is not the same thing at every level.

FamilyWhat it does for youWhat you still ownTypical cost
Raw ADO.NET (DbConnection, DbCommand, DbDataReader)Nothing beyond the providerSQL, parameters, mapping, lifetime, transactionsMost code, least magic, no surprises
Micro-ORM (Dapper, RepoDB, SqlMapper)Parameter binding and result mapping into objectsSQL text, query shape, and the schema contractTiny runtime overhead; SQL is hand-written
LINQ-based O/RM (EF Core, linq2db, NHibernate)Query translation, change tracking, migrations, relationship fix-upModel design, query shaping, and the decision of when to drop to SQLTranslation cost, tracking cost, plan-cache effects
Document / event mapper (Marten, EF Core Cosmos provider)Serialization, identity, and querying over non-tabular storesDocument shape, versioning of stored payloadsSerializer coupling; weaker ad-hoc querying

The decision is not “which is faster”. Dapper wins raw microbenchmarks almost always, because it does almost nothing: no expression tree to parse, no change tracker, no identity map. EF Core wins on the work it removes — migrations, relationship management, and the ability to express a query once in typed code. Both statements are true at the same time, which is why mature systems mix them.

2. EF Core — a full O/RM

EF Core maps a .NET object model to a relational schema and translates LINQ expression trees into SQL. Its value is not the SELECT it writes; it is the lifecycle around that SELECT.

2.1 The model and the mapping

public sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> b)
    {
        b.ToTable("Orders");
        b.HasKey(o => o.Id);
        b.Property(o => o.Number).HasMaxLength(32).IsRequired();
        b.HasIndex(o => new { o.CustomerId, o.CreatedAt });

        // Complex type: value semantics, no identity, one JSON column
        b.ComplexProperty(o => o.Shipping, c => c.ToJson());
    }
}

2.2 The change tracker

Reads that go through the tracker pay for identity resolution and snapshotting. That is what makes SaveChanges able to compute a diff — and what makes a 50 000-row read in a request expensive for no reason.

// read-only API path: no tracking, no identity map
var list = await db.Orders
    .AsNoTracking()
    .Where(o => o.CustomerId == id)
    .OrderByDescending(o => o.CreatedAt)
    .Select(o => new OrderDto(o.Id, o.Number, o.Total))
    .Take(50)
    .ToListAsync(ct);

// write path: tracked, one unit of work
var order = await db.Orders.Include(o => o.Lines).SingleAsync(o => o.Id == id, ct);
order.AddLine(sku, qty);
await db.SaveChangesAsync(ct);
Rule of thumb. Project to DTOs for reads; load tracked entities only when you intend to mutate them in the same unit of work. AsNoTracking() plus Select removes most of the “EF is slow” anecdotes before they happen.

2.3 What EF Core does well

CapabilityWhy it matters in a real API
MigrationsSchema changes are versioned, reviewable and repeatable across environments
Relationship fix-upLoading an aggregate graph does not require hand-written joins
Optimistic concurrency[Timestamp]/rowversion plus DbUpdateConcurrencyException out of the box
BatchingMultiple inserts/updates are grouped into fewer round-trips
Provider portabilitySQL Server, PostgreSQL, SQLite, Cosmos and others behind one model
Global query filtersSoft delete and multi-tenancy applied consistently by default

2.4 Where it costs you

3. What EF Core 10 changed

EF Core 10 shipped in November 2025 as an LTS release supported until November 2028, and requires the .NET 10 runtime. The items below change day-to-day design decisions, not just micro-performance.

ChangePractical consequence
Named query filtersSeveral filters per entity, disabled selectively: IgnoreQueryFilters(["SoftDeletionFilter"]) instead of turning everything off
Complex types (incl. optional, structs, JSON mapping)Value-semantics replacement for owned types; assigning one to another copies instead of failing, and ExecuteUpdateAsync works on them
Parameterized collections: one parameter per value, paddedSolves the plan-cache bloat of inlined IN (...) while keeping cardinality visible to the planner; switchable per query with EF.Constant()
LeftJoin/RightJoinFirst-class LINQ operators from .NET 10, no more GroupJoin+DefaultIfEmpty gymnastics
ExecuteUpdateAsync with a normal lambdaConditional setters without hand-built expression trees
JSON column support (SQL Server 2025)Native json type, JSON_VALUE translation, ExecuteUpdate against properties inside the document
Vector type and VectorDistanceSimilarity search in LINQ for Azure SQL / SQL Server 2025 embeddings
Redaction of inlined constantsInlined values are logged as ? by default — PII stops leaking into logs
Split-query ordering fixRelated data loaded by split queries is deterministic under OrderBy + Take
Migration warning. With SQL Server 2025 compatibility level 170 or UseAzureSql, existing nvarchar JSON columns are converted to the new json type on the first migration. If that is not what you want, pin the column type or lower the compatibility level before generating it.

4. Dapper — a micro-ORM

Dapper adds three things on top of DbConnection: parameter binding from an object, mapping of the result set into objects, and a set of helpers for multi-mapping and multi-result queries. Everything else — SQL, schema coupling, transaction scope — stays yours.

4.1 The core API

const string sql = @"
SELECT o.Id, o.Number, o.Total, c.Name AS CustomerName
FROM Orders o
JOIN Customers c ON c.Id = o.CustomerId
WHERE o.CustomerId = @customerId AND o.CreatedAt >= @from
ORDER BY o.CreatedAt DESC
OFFSET @skip ROWS FETCH NEXT @take ROWS ONLY;";

var rows = await connection.QueryAsync<OrderRow>(sql, new
{
    customerId,
    from,
    skip = (page - 1) * size,
    take = size
});

4.2 Multi-mapping and multiple result sets

var sql = @"
SELECT o.Id, o.Number, l.Sku, l.Quantity
FROM Orders o
LEFT JOIN OrderLines l ON l.OrderId = o.Id
WHERE o.Id = @id;";

var order = (await connection.QueryAsync<Order, Line, Order>(
    sql,
    (o, l) => { o.Lines.Add(l); return o; },
    new { id },
    splitOn: "Sku")).First();

// two result sets, one round-trip
using var multi = await connection.QueryMultipleAsync(
    "SELECT * FROM Orders WHERE Id = @id; SELECT * FROM OrderLines WHERE OrderId = @id;",
    new { id });
var head = await multi.ReadSingleAsync<Order>();
var lines = (await multi.ReadAsync<Line>()).ToList();

4.3 What you must remember

4.4 When Dapper is the right answer

5. Other frameworks

FrameworkModelStrengthsWatch out for
ADO.NETRaw commands and readersTotal control, zero abstraction, useful for DDL and provider-specific featuresBoilerplate, manual mapping, easy to leak connections
NHibernateFull O/RM, session-based, HQL/CriteriaMature second-level cache, rich mapping, strong in legacy .NET Framework estatesSteeper learning curve; less common in new .NET Core work
linq2dbLINQ over SQL with explicit query buildingVery predictable SQL, fast, supports CTEs and temp tables wellSmaller ecosystem than EF Core; manual schema mapping
MartenDocument DB and event store on PostgreSQLAggregates as documents, built-in event sourcing, LINQ queryingTies the model to PostgreSQL; document versioning is on you
RepoDBHybrid micro-ORMFluent and raw operations, bulk merge, caching layerLess community material; conventions must be learned
SqlKata / query buildersComposable SQL builderDynamic filters without string concatenation, multi-dialectStill hand-mapped results; not an O/RM
Default for a new .NET 8+ API: EF Core for the write model and the aggregate lifecycle, Dapper (or raw SQL through FromSql) for reporting reads. Two tools, one schema, clear boundary.

6. Side-by-side comparison

CriterionEF CoreDapperRaw ADO.NET
Query languageLINQ translated to SQLHand-written SQLHand-written SQL
Change trackingYes, opt-outNoNo
MigrationsBuilt inExternal toolExternal tool
MappingAutomatic, conventions + configAutomatic by column nameManual reader loop
Overhead per rowMaterialization + tracking (if enabled)Reflection-emitted mapper, very lowLowest
Complex SQLPossible but fights the translatorNativeNative
Learning curveHigh (model + translation rules)LowLow, but verbose
TestabilityIn-memory/SQLite providers, but behavior differs from productionNeeds a real databaseNeeds a real database
Best fitDomain writes, CRUD, aggregatesReads, reports, bulk, legacy schemaDDL, maintenance jobs, provider-specific features

7. Patterns

7.1 Repository and unit of work

DbContext is already a unit of work and a repository over its DbSets. Wrapping it in IRepository<T> usually produces a leaky abstraction: IQueryable escapes anyway, or you lose Include, projections and filters. Prefer a use-case-oriented interface over a generic one.

// Prefer this
public interface IOrderStore
{
    Task<Order?> FindAsync(OrderId id, CancellationToken ct);
    Task AddAsync(Order order, CancellationToken ct);
    Task<IReadOnlyList<OrderSummary>> ListRecentAsync(Guid customerId, int take, CancellationToken ct);
}

// Over this: it re-exposes the ORM with extra steps
public interface IRepository<T> where T : class
{
    IQueryable<T> Query();
    Task<T?> GetByIdAsync(object id);
    // ...
}

7.2 The read/write split

Keep the write model expressive (EF Core, aggregates, invariants) and let reads go straight to SQL (Dapper) with DTOs shaped for the screen or the API contract. The boundary is a namespace, not a distributed system:

Orders/            // EF Core: aggregates, invariants, SaveChanges
Orders/Queries/    // Dapper: SELECTs returning DTOs
Infrastructure/    // DbContext, connection factory, migrations

7.3 Specifications

Useful when the same filtering rule is reused across use cases; harmful when it becomes a second query language. If a specification needs three Includes and a projection, it is a query, not a specification.

8. Performance: where each one hurts

8.1 Round-trip arithmetic

Data-access cost is dominated by round-trips, not by mapping. If a request performs n queries at latency L, the floor is:

T_response ≥ n × L_db + T_cpu(mapping + serialization)

A 2 ms local query becomes 40 ms across an availability zone. This is why N+1 hurts more in production than on a laptop, and why batching beats micro-optimizing the mapper.

8.2 Frequent EF Core fixes

8.3 Frequent Dapper fixes

Measurement discipline. Benchmark with BenchmarkDotNet for micro-costs and load-test the endpoint for the real number. A mapper that is 3× faster changes nothing if 95% of the time is the database.

9. Transactions, concurrency and migrations

9.1 One transaction, two tools

EF Core and Dapper can share a transaction: open it on the DbContext connection and pass the same connection and transaction to Dapper.

await using var tx = await db.Database.BeginTransactionAsync(ct);

var order = await db.Orders.SingleAsync(o => o.Id == id, ct);
order.MarkPaid();
await db.SaveChangesAsync(ct);              // EF Core

await db.Database.GetDbConnection().ExecuteAsync(
    "INSERT INTO AuditLog (EntityId, Action, At) VALUES (@id, 'paid', SYSUTCDATETIME())",
    new { id }, tx);                         // Dapper, same transaction

await tx.CommitAsync(ct);

9.2 Concurrency

9.3 Migrations in delivery

10. Testing the data layer

ApproachGood forNot good for
EF Core InMemory providerFast unit tests of business logicAnything about SQL, constraints, transactions — it is not a relational database
SQLite in-memoryRelational behavior in fast testsProvider-specific SQL, concurrency, case sensitivity
Testcontainers (SQL Server/PostgreSQL)Real plans, real constraints, migrations, Dapper queriesSpeed — use it for integration suites, not for every test
WebApplicationFactory + TestcontainersEnd-to-end API tests with the real data layerSub-second feedback loops
Rule. Test the SQL you ship. In-memory providers hide exactly the bugs that reach production: missing indexes, constraint violations, translation differences and transaction semantics.

11. Decision guide

  1. Is the domain non-trivial and mutated through invariants? EF Core for the write model. If not, a micro-ORM or raw SQL is enough.
  2. Does the read need SQL that LINQ expresses badly (window functions, CTEs, pivots, MERGE)? Dapper for that query, in the same project.
  3. Is the schema owned by the team? If yes, EF migrations. If not, external migration tooling that matches the DBA process.
  4. Is the hot path measured? Optimize the round-trips and the plan first; swap the mapper only if it is provably the bottleneck.
  5. Can two mappers drift? Keep one schema, one migration source, and tests that exercise both paths against a real database.

Review checklist

12. Sources