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.
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?
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.
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.
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
Includenavigation 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.
| Concern | EF Core LINQ | EF Core FromSql | Dapper |
|---|---|---|---|
| Change tracking | Yes | Yes (unless AsNoTracking) | No |
| LINQ composability | Full | Partial (on top of FROM) | None |
| Global Query Filters | Automatic | Automatic | Must write manually |
| Custom SQL control | None | Full for the FROM clause | Full |
| Non-entity result shapes | Via Select projection | Requires keyless entity type | Any class or primitive |
| Multi-mapping joins | Via Include | Via Include | Manual but flexible |
| Transaction sharing with EF | Native | Native | Via GetDbTransaction() |
| SQL injection safety | Automatic | Safe with FromSql/positional params | Safe with @param syntax |
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.
- Add a keyless entity type: If you have reporting queries that return custom shapes, register them with
modelBuilder.Entity<ReportRow>().HasNoKey().ToView(null)and useFromSqlRawto 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.StoredProceduresupport makes it the cleanest integration path without touching EF's entity model.
No comments: