.NET Programming With Me

Raw SQL, FromSqlRaw, and Dapper Side-by-Side in a .NET 8 API

EF Core's LINQ query builder handles the vast majority of data access scenarios cleanly, but every production application eventually hits a query complex enough that generating it through LINQ is more effort than writing the SQL directly. At that point you have three realistic options: EF Core's own raw SQL APIs (FromSql and ExecuteSqlRaw), Dapper running over the same database connection, or a mix of both.

All three approaches talk to the same database, can share the same connection and transaction, and produce parameterized queries. The differences are in what they give back, how much ceremony they require, and where they break down.

By the end of this post you will have working code for all three approaches solving the same query, a clear decision framework for choosing between them, and a pattern for combining EF Core and Dapper in the same API without fighting over the connection.


1 A quick history first

EF Core raw SQL (FromSqlRaw, FromSql, ExecuteSqlRaw) was added to give EF developers an escape hatch when the LINQ translator cannot express a query, while still returning tracked entities and allowing LINQ composition on top of the raw SQL result.

Dapper is a micro-ORM by the Stack Overflow team built on top of IDbConnection. It maps query results to objects via property name matching and supports multi-mapping for joins. It has no change tracking, no relationship model, and no query builder; you write SQL, Dapper materializes the result.

Both evolved alongside LINQ-to-SQL and NHibernate as answers to the same question: what do you do when a generated query is not good enough?


2 The same task, three ways

The scenario: fetch orders for a given customer that include their line items and the product name for each line. This query involves a join, a filter, and a result shape that does not map cleanly to a single entity.

EF Core LINQ (baseline)

var orders = await _context.Orders
    .AsNoTracking()
    .Include(o => o.Lines)
        .ThenInclude(l => l.Product)
    .Where(o => o.CustomerId == customerId
             && o.Status == OrderStatus.Pending)
    .OrderByDescending(o => o.PlacedAt)
    .ToListAsync(ct);

EF Core FromSql approach

// FromSql (EF Core 8+) uses interpolated strings safely — parameters are never inlined
var orders = await _context.Orders
    .FromSql($@"
        SELECT o.*
        FROM   orders o
        WHERE  o.customer_id = {customerId}
          AND  o.status      = {(int)OrderStatus.Pending}
        ORDER BY o.placed_at DESC")
    .AsNoTracking()
    .Include(o => o.Lines)
        .ThenInclude(l => l.Product)
    .ToListAsync(ct);

Dapper approach

using var connection = _context.Database.GetDbConnection();
await connection.OpenAsync(ct);

var orderDict = new Dictionary<int, Order>();

await connection.QueryAsync<Order, OrderLine, Product, Order>(
    sql: @"
        SELECT o.id, o.customer_id, o.status, o.placed_at,
               l.id, l.order_id, l.quantity, l.unit_price,
               p.id, p.name, p.sku
        FROM   orders       o
        JOIN   order_lines  l ON l.order_id  = o.id
        JOIN   products     p ON p.id        = l.product_id
        WHERE  o.customer_id = @CustomerId
          AND  o.status      = @Status
        ORDER BY o.placed_at DESC",
    map: (order, line, product) =>
    {
        if (!orderDict.TryGetValue(order.Id, out var existing))
        {
            existing = order;
            existing.Lines = [];
            orderDict[order.Id] = existing;
        }
        line.Product = product;
        existing.Lines.Add(line);
        return existing;
    },
    param: new { CustomerId = customerId, Status = (int)OrderStatus.Pending },
    splitOn: "id,id");

var orders = orderDict.Values.ToList();

The SQL in the EF and Dapper versions is nearly identical. The biggest visible difference is that EF Core can compose Include on top of the raw SQL, while Dapper requires you to write the full join and wire up the object graph yourself.


3 The real tradeoffs

1. What you get back

EF Core raw SQL returns full entity instances that participate in the change tracker and can have navigation properties populated via Include. Dapper returns plain objects: whatever class you tell it to map to, with no change tracking and no navigation resolution built in. If you want the graph assembled from a join, you write the multi-mapping lambda yourself as shown above.

Does this matter? If you need to modify returned records or rely on navigation lazy loading, EF Core raw SQL is the right tool. If you are building a read model or projecting to a DTO, Dapper's lack of overhead is an advantage.

2. SQL injection safety

All three options support parameterized queries and are safe when used correctly, but the APIs make it easier or harder to do the wrong thing.

// Safe: FromSql with interpolated string — EF Core parameterizes {customerId} automatically
var orders = await _context.Orders
    .FromSql($"SELECT * FROM orders WHERE customer_id = {customerId}")
    .ToListAsync(ct);

// UNSAFE: FromSqlRaw with string interpolation — inlines the value directly into SQL
var orders = await _context.Orders
    .FromSqlRaw($"SELECT * FROM orders WHERE customer_id = {customerId}") // never do this
    .ToListAsync(ct);

// Safe: FromSqlRaw requires explicit parameter objects
var orders = await _context.Orders
    .FromSqlRaw("SELECT * FROM orders WHERE customer_id = {0}", customerId)
    .ToListAsync(ct);

The rule for FromSqlRaw: treat the first argument exactly like a string.Format call and never pass a user-derived value as anything other than a positional placeholder argument. For new code in EF Core 8, prefer FromSql (the interpolated string overload) because the compiler enforces correct usage.

3. LINQ composability

EF Core raw SQL results can have additional LINQ operators chained after them as long as the raw SQL forms the entire FROM clause:

// Legal: filter and sort on top of raw SQL
var page = await _context.Orders
    .FromSql($"SELECT * FROM orders WHERE customer_id = {customerId}")
    .Where(o => o.Status == OrderStatus.Pending)
    .OrderByDescending(o => o.PlacedAt)
    .Skip(offset).Take(pageSize)
    .ToListAsync(ct);

Dapper has no composition layer: once the SQL is written, the result is fixed. Any further filtering or pagination must happen either in SQL or in memory after the query returns, which can be a significant limitation for dynamic filter scenarios.

4. Non-query commands: ExecuteSqlRaw vs Dapper Execute

For UPDATE, DELETE, or stored procedure calls that do not return rows, both approaches are equivalent in ceremony:

// EF Core: bulk update without loading entities
int rows = await _context.Database.ExecuteSqlRawAsync(
    "UPDATE orders SET status = {0} WHERE customer_id = {1} AND placed_at < {2}",
    (int)OrderStatus.Cancelled, customerId, cutoff, ct);

// Dapper: same operation
using var connection = _context.Database.GetDbConnection();
int rows = await connection.ExecuteAsync(
    "UPDATE orders SET status = @Status WHERE customer_id = @CustomerId AND placed_at < @Cutoff",
    new { Status = (int)OrderStatus.Cancelled, CustomerId = customerId, Cutoff = cutoff });

For bulk operations in EF Core 8, prefer ExecuteUpdateAsync and ExecuteDeleteAsync over raw SQL commands because they compose with Global Query Filters and keep the tenant and soft-delete predicates active.

5. Sharing a transaction across EF Core and Dapper

Because Dapper works directly on an IDbConnection, it can participate in the same transaction that EF Core opened:

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

// EF Core write
_context.Orders.Add(newOrder);
await _context.SaveChangesAsync(ct);

// Dapper write in the same transaction
var connection   = _context.Database.GetDbConnection();
var dbTransaction = _context.Database.CurrentTransaction!.GetDbTransaction();

await connection.ExecuteAsync(
    "INSERT INTO audit_log (order_id, action, ts) VALUES (@OrderId, @Action, @Ts)",
    new { OrderId = newOrder.Id, Action = "created", Ts = DateTimeOffset.UtcNow },
    transaction: dbTransaction);

await tx.CommitAsync(ct);

This pattern lets you use EF Core's change tracking for the entities it manages well and fall back to Dapper for queries or commands that do not fit the EF Core model, all within the same transactional boundary.


4 Which one should you choose?

Choose based on what you need from the result, not on habit or familiarity with one library.

Use EF Core LINQ when:

  • The query can be expressed in LINQ without gymnastics and the generated SQL is acceptable.
  • You need to modify returned entities in the same unit of work.
  • You want Global Query Filters (soft delete, multi-tenancy) applied automatically.
  • The query benefits from Include navigation loading without writing a join manually.

Use EF Core FromSql when:

  • The LINQ translation produces a suboptimal query (extra joins, missing index hints, missing CTEs) and you want to write the SQL yourself.
  • You still need to return tracked entities and compose LINQ on top of the result.
  • You are calling a view or a stored procedure that returns a shape matching an existing entity.
  • You want the safety of parameterized queries with minimal friction over raw ADO.NET.

Use Dapper when:

  • The result does not map to an entity: reporting queries, aggregations, complex multi-table projections, or custom DTO shapes.
  • You need multi-mapping (assembling a graph from a single join query) with fine-grained control over the mapping logic.
  • Performance is critical and you want the thinnest possible layer between SQL and the materialized object.
  • You are integrating a legacy stored-procedure-heavy database where no EF entity model exists.

5 Quick reference summary
Concern EF Core LINQ EF Core FromSql Dapper
Change trackingYesYes (unless AsNoTracking)No
LINQ composabilityFullPartial (on top of FROM)None
Global Query FiltersAutomaticAutomaticMust write manually
Custom SQL controlNoneFull for the FROM clauseFull
Non-entity result shapesVia Select projectionRequires keyless entity typeAny class or primitive
Multi-mapping joinsVia IncludeVia IncludeManual but flexible
Transaction sharing with EFNativeNativeVia GetDbTransaction()
SQL injection safetyAutomaticSafe with FromSql/positional paramsSafe with @param syntax

Final take

These three tools are not competitors; they are layers. Start with EF Core LINQ. Drop to FromSql when the generated SQL is the problem but you still want entity tracking and LINQ composition. Drop to Dapper when the result shape does not fit the entity model at all or when you need the absolute minimum between your SQL and your object.

If you find yourself using FromSqlRaw everywhere because LINQ frustrates you, that is a signal your entity model may be fighting your query patterns; revisit the schema or consider a dedicated read model with Dapper for query-side endpoints.

What's next?
  • Add a keyless entity type: If you have reporting queries that return custom shapes, register them with modelBuilder.Entity<ReportRow>().HasNoKey().ToView(null) and use FromSqlRaw to return them through EF Core with full LINQ composability.
  • Benchmark your heavy queries: Use BenchmarkDotNet with both approaches on your most-called endpoints and measure allocations, not just execution time.
  • Review your stored procedures: If your team owns legacy stored procedures, Dapper's commandType: CommandType.StoredProcedure support makes it the cleanest integration path without touching EF's entity model.
Got a question or a scenario I did not cover? Drop a comment below and I will reply.

No comments: