AI WisdomArchitecture & guides β†—
HT
How Things Work

Raw SQL & Stored Procedures

When to escape the LINQ abstraction β€” and how FromSqlRaw, ExecuteSqlRaw, and Dapper complement EF Core.

How It Works

EF Core's LINQ translation covers 90% of database operations cleanly. The remaining 10% β€” window functions, complex aggregations, bulk mutations, stored procedures with output parameters, and arbitrary CTEs β€” are better served by raw SQL. EF Core 8/9 provides excellent raw SQL APIs that keep results type-safe and integrated with the change tracker when needed. The key rules: always use parameterized queries (FromSqlInterpolated or explicit parameters), understand what bypasses the change tracker and concurrency tokens, and know when to reach for Dapper on the same connection.

1
FromSqlRaw vs FromSqlInterpolated β€” The Safety Difference

FromSqlRaw takes a plain string β€” SQL injection is your responsibility. If you pass a string literal it's safe. If you interpolate user input into it (via $""), it's an injection vector. FromSqlInterpolated takes a FormattableString and EF extracts interpolated values as SQL parameters automatically. Use FromSqlInterpolated whenever you have dynamic values. Never use string concatenation or $"" with FromSqlRaw.

2
FromSqlRaw β€” How Results Are Materialized

FromSqlRaw returns IQueryable<TEntity> backed by the raw SQL. EF materializes results by matching column names in the result set to entity property names (by convention). The entities enter the change tracker just like normal queries. You can chain .Include(), .Where(), .OrderBy() after FromSqlRaw β€” EF wraps your SQL in a CTE and adds the LINQ clauses on top.

3
ExecuteUpdate / ExecuteDelete β€” EF Core 7+ Bulk Operations

ExecuteUpdateAsync and ExecuteDeleteAsync translate to a single UPDATE or DELETE SQL statement without loading entities into memory. Zero change tracker overhead. But they bypass all EF-level checks: concurrency tokens ([Timestamp]) are NOT evaluated, interceptors may or may not fire (depends on implementation), and SaveChanges events don't fire.

4
SqlQueryRaw<T> β€” Arbitrary Result Shapes (EF Core 8+)

Database.SqlQueryRaw<T>() lets you execute raw SQL and materialize results into any type T β€” not just entity types. T can be a DTO, a record, or even a primitive. Column names in the result set must match property names in T. Results are not tracked. Useful for window functions, CTEs, aggregations that don't map to entity shapes.

5
Stored Procedures and Output Parameters

EF can execute stored procedures via ExecuteSqlRaw() or FromSqlRaw(). Output parameters require SqlParameter with Direction = ParameterDirection.Output, passed directly to ExecuteSqlRawAsync. Stored procedures with multiple result sets (EXEC sp_GetCustomerWithOrders) are not supported by FromSqlRaw β€” use Dapper or ADO.NET directly for multi-result-set procedures.

6
Dapper Alongside EF β€” Shared Connection

Dapper and EF Core can share the same DbConnection, obtained via _dbContext.Database.GetDbConnection(). If you have an ambient transaction (opened with _dbContext.Database.BeginTransaction()), Dapper queries on the same connection participate in it automatically. This lets you use Dapper for complex read queries while EF handles writes β€” without managing separate connections.

Key Concepts

πŸ“FromSqlRaw()

Executes raw SQL and materializes results as tracked entity instances. The SQL must return all columns of the entity (or EF throws). Can be composed with LINQ operators β€” EF wraps the SQL in a CTE. Safe only with string literals or parameterized values, never with string interpolation of user input.

πŸ›‘FromSqlInterpolated()

Like FromSqlRaw but accepts a FormattableString ($"..."). EF automatically extracts interpolated values as parameterized SQL arguments, preventing SQL injection. Equivalent to FromSqlRaw with explicit parameters but with safer syntax. Prefer over FromSqlRaw for any dynamic values.

✏ExecuteUpdateAsync()

EF Core 7+. Translates a LINQ Where + SetProperty chain to a single UPDATE statement. No entity loading, no change tracker. Runs immediately β€” does not wait for SaveChanges(). Bypasses concurrency tokens. Returns int (rows affected).

πŸ—‘ExecuteDeleteAsync()

EF Core 7+. Translates a LINQ Where to a single DELETE statement. No entity loading. Does not fire SaveChanges events. Bypasses soft-delete global query filters unless you explicitly apply them. Bypasses concurrency tokens.

πŸ”§SqlQueryRaw<T>()

EF Core 8+. Available on DbContext.Database. Executes raw SQL and materializes results into any type T β€” not just entity types. Useful for aggregations, window functions, CTEs. T properties must match result column names. Results are never change-tracked.

⚑Dapper

Lightweight micro-ORM by StackExchange. Executes raw SQL and maps results to typed objects via reflection. Can share EF's DbConnection and transaction. Ideal for complex reads (window functions, CTEs, stored procs with multiple result sets) that are difficult or impossible to express in LINQ.

EF Core 9 β€” FromSqlRaw, ExecuteUpdate, SqlQueryRaw, Dapper
tsx
1// EF Core 9 β€” Raw SQL, Stored Procedures, Dapper
2
3// FromSqlRaw β€” materialized to tracked entities
4// βœ… Safe: SQL is fixed, params are parameterized
5var orders = await _dbContext.Orders
6 .FromSqlRaw(
7 "SELECT * FROM Orders WHERE CustomerId = {0} AND Status = {1}",
8 customerId, "Pending")
9 .Include(o => o.Items) // Can chain Include() after FromSqlRaw!
10 .AsNoTracking()
11 .ToListAsync(ct);
12
13// FromSqlInterpolated β€” SAFE interpolation via FormattableString
14// EF extracts interpolated values as SQL parameters automatically
15var minAmount = 100m;
16var orders = await _dbContext.Orders
17 .FromSqlInterpolated(
18 $"SELECT * FROM Orders WHERE TotalAmount > {minAmount} AND Status = {'Pending'}")
19 .AsNoTracking()
20 .ToListAsync(ct);
21
22// ❌ SQL INJECTION β€” NEVER do this with FromSqlRaw:
23var status = Request.Query["status"]; // untrusted user input
24var orders = await _dbContext.Orders
25 .FromSqlRaw($"SELECT * FROM Orders WHERE Status = '{status}'") // ← INJECTION
26 .ToListAsync(ct);
27// If status = "'; DROP TABLE Orders; --" you've lost your data
28
29// ExecuteSqlRaw β€” for non-query SQL (UPDATE, DELETE, INSERT)
30// EF Core 7+ bulk update via ExecuteUpdate (preferred over ExecuteSqlRaw for simple cases)
31var rowsAffected = await _dbContext.Orders
32 .Where(o => o.Status == "Abandoned" && o.PlacedAt < DateTime.UtcNow.AddDays(-30))
33 .ExecuteUpdateAsync(set => set
34 .SetProperty(o => o.Status, "Archived")
35 .SetProperty(o => o.ArchivedAt, DateTime.UtcNow), ct);
36// Single UPDATE statement β€” no entity loading, no change tracker
37// ⚠ Bypasses concurrency tokens β€” [Timestamp] NOT checked
38
39// ExecuteSqlRaw β€” raw UPDATE for complex expressions
40var affected = await _dbContext.Database.ExecuteSqlRawAsync(
41 "UPDATE Orders SET TotalAmount = TotalAmount * {0} WHERE CustomerId = {1}",
42 discountFactor, customerId, ct);
43
44// Window function β€” LINQ can't express this, use FromSqlRaw
45var ranked = await _dbContext.Database
46 .SqlQueryRaw<CustomerRankDto>(
47 @"SELECT c.Id, c.Name,
48 SUM(o.TotalAmount) AS LifetimeValue,
49 ROW_NUMBER() OVER (ORDER BY SUM(o.TotalAmount) DESC) AS Rank
50 FROM Customers c
51 JOIN Orders o ON o.CustomerId = c.Id
52 GROUP BY c.Id, c.Name")
53 .ToListAsync(ct);
54// SqlQueryRaw<T> (EF Core 8+) β€” for arbitrary result shapes, not entity types
55
56// Stored procedure β€” output parameter
57var outputParam = new SqlParameter("@TotalOrders", SqlDbType.Int) { Direction = ParameterDirection.Output };
58await _dbContext.Database.ExecuteSqlRawAsync(
59 "EXEC GetCustomerStats @CustomerId = {0}, @TotalOrders = @TotalOrders OUTPUT",
60 customerId, outputParam);
61var total = (int)outputParam.Value;
62
63// Dapper alongside EF β€” shared connection
64using var connection = _dbContext.Database.GetDbConnection();
65if (connection.State != ConnectionState.Open)
66 await connection.OpenAsync(ct);
67
68var results = await connection.QueryAsync<CustomerSummary>(
69 @"SELECT c.Id, c.Name, COUNT(o.Id) AS OrderCount
70 FROM Customers c
71 LEFT JOIN Orders o ON o.CustomerId = c.Id
72 GROUP BY c.Id, c.Name
73 ORDER BY OrderCount DESC
74 OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY",
75 new { offset = (page - 1) * pageSize, pageSize });
76// Dapper uses EF's connection β€” same transaction scope if wrapped in BeginTransaction()
πŸ’‘
Why This Matters

Raw SQL in EF Core is not a smell β€” it's a tool for when the abstraction leaks. Window functions and bulk mutations are the most common cases. The critical discipline is: never concatenate user input into SQL strings, understand what bypasses EF's safety nets (concurrency tokens, global query filters, SaveChanges interceptors), and use Dapper on EF's shared connection rather than opening a second connection.

Common Pitfalls

⚠FromSqlRaw($"...{userInput}...") β€” C# string interpolation produces a plain string before EF sees it. The SQL is already injected by the time EF receives the argument. Use FromSqlInterpolated (safe $"" interpolation) or explicit {0} parameters with FromSqlRaw. This is the #1 SQL injection vector in EF Core codebases.
⚠ExecuteUpdate and ExecuteDelete bypass concurrency tokens ([Timestamp], [ConcurrencyToken]). If you use optimistic concurrency on an entity type, bulk operations via ExecuteUpdate will not check rowversion and can overwrite rows without conflict detection.
⚠ExecuteUpdate and ExecuteDelete bypass global query filters by default. If you have a soft-delete filter (e.g., .HasQueryFilter(e => !e.IsDeleted)), ExecuteDelete will delete soft-deleted rows too unless you explicitly add the filter condition in your Where() call.
⚠Stored procedures that return multiple result sets (SELECT ... ; SELECT ...) are not supported by FromSqlRaw β€” EF only reads the first result set. Use Dapper (QueryMultiple) or raw ADO.NET DataReader for multi-result-set procs.
Real-World Use Cases

1Window Function for Customer Ranking β€” LINQ Couldn't Express It

Scenario

Our customer success team needed a leaderboard: top customers by lifetime value with their rank, quartile, and delta vs previous month β€” updated daily via a background job. We tried writing this in LINQ for three days. EF kept generating incorrect GROUP BY clauses and couldn't express ROW_NUMBER() OVER() or NTILE(4) at all.

Problem

LINQ's GroupBy has limited SQL translation in EF Core. Window functions (ROW_NUMBER, NTILE, LAG, LEAD, SUM OVER PARTITION BY) have no LINQ equivalents. The closest approach β€” loading all customers and computing ranks in C# β€” would have loaded 250,000 rows into memory for a job that ran on a 4MB Lambda function.

Solution

Used Database.SqlQueryRaw<CustomerRankDto>() with a CTE that computed lifetime value, ROW_NUMBER() OVER (ORDER BY LifetimeValue DESC), and NTILE(4) OVER (...) in a single SQL query. The query ran in 180ms returning 250 rows (top-250 only). The lambda's memory usage stayed under 80MB. The LINQ equivalent (load all, compute in C#) would have been 4GB+.

πŸ’‘

Takeaway: Window functions, recursive CTEs, and PIVOT/UNPIVOT are legitimate reasons to escape LINQ. Use Database.SqlQueryRaw<T>() (EF Core 8+) for these cases β€” you get type-safe results without the overhead of loading full entities.

2ExecuteUpdate Replaced 45-Second Batch Job with 200ms SQL

Scenario

A nightly job archived abandoned orders (status=Abandoned, older than 30 days) by loading them with .ToListAsync(), iterating each, setting status = Archived, and calling SaveChangesAsync(). In dev (500 orders) it ran in 8 seconds. In production (180,000 abandoned orders), it timed out at 45 minutes, consumed 6GB of memory, and held thousands of SQL connections.

Problem

The job loaded 180,000 full Order entities into memory (with change tracking), mutated each one, then called SaveChanges which generated 180,000 individual UPDATE statements. Each UPDATE was a round-trip. The change tracker's snapshot comparison ran on every entity. The SQL Server connection pool was exhausted within 3 minutes.

Solution

Replaced the entire job with: await _dbContext.Orders.Where(o => o.Status == "Abandoned" && o.PlacedAt < cutoff).ExecuteUpdateAsync(set => set.SetProperty(o => o.Status, "Archived").SetProperty(o => o.ArchivedAt, DateTime.UtcNow)); β€” a single UPDATE statement. Ran in 200ms, zero memory allocation for entities, one SQL round-trip.

πŸ’‘

Takeaway: ExecuteUpdate/ExecuteDelete (EF Core 7+) is the correct tool for bulk mutations. Never load entities into memory just to mutate them in a loop β€” that's an O(N) entity load + N round-trips. One SQL statement handles any N. Just remember: it bypasses concurrency tokens and EF-level interceptors.

3SQL Injection via FromSqlRaw String Interpolation in a Search Endpoint

Scenario

A security audit flagged a product search endpoint. The developer had written: FromSqlRaw($"SELECT * FROM Products WHERE Name LIKE '%{searchTerm}%'"). The searchTerm came from a query parameter, URL-decoded but not sanitized. Our penetration tester passed: '; SELECT * FROM Customers; --

Problem

The $"" syntax in C# creates a string β€” interpolated before EF ever sees it. By the time FromSqlRaw received the argument, it was a complete SQL string with the injected fragment already embedded. The second SELECT returned the entire Customers table in the result set (EF ignored it, but ADO.NET had executed it). A UNION-based injection would have returned customer data to the caller.

Solution

Replaced FromSqlRaw($"...") with FromSqlInterpolated($"..."). Identical syntax, completely different behavior β€” EF extracts the interpolated values as SqlParameter objects. Alternatively: FromSqlRaw("SELECT * FROM Products WHERE Name LIKE {0}", $"%{searchTerm}%") with explicit parameter. Added a Roslyn analyzer to the repo that flags FromSqlRaw with string interpolation.

πŸ’‘

Takeaway: The most dangerous EF Core footgun: FromSqlRaw with $"" string interpolation looks safe because $"" looks like parameterization in other frameworks. It is not. FromSqlInterpolated is the safe version. Add a code review checklist item and IDE rule to never use $"" with FromSqlRaw.