Raw SQL & Stored Procedures
When to escape the LINQ abstraction β and how FromSqlRaw, ExecuteSqlRaw, and Dapper complement EF Core.
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.
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.
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.
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.
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.
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.
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
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.
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.
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).
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.
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.
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.
1// EF Core 9 β Raw SQL, Stored Procedures, Dapper23// FromSqlRaw β materialized to tracked entities4// β Safe: SQL is fixed, params are parameterized5var orders = await _dbContext.Orders6 .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);1213// FromSqlInterpolated β SAFE interpolation via FormattableString14// EF extracts interpolated values as SQL parameters automatically15var minAmount = 100m;16var orders = await _dbContext.Orders17 .FromSqlInterpolated(18 $"SELECT * FROM Orders WHERE TotalAmount > {minAmount} AND Status = {'Pending'}")19 .AsNoTracking()20 .ToListAsync(ct);2122// β SQL INJECTION β NEVER do this with FromSqlRaw:23var status = Request.Query["status"]; // untrusted user input24var orders = await _dbContext.Orders25 .FromSqlRaw($"SELECT * FROM Orders WHERE Status = '{status}'") // β INJECTION26 .ToListAsync(ct);27// If status = "'; DROP TABLE Orders; --" you've lost your data2829// 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.Orders32 .Where(o => o.Status == "Abandoned" && o.PlacedAt < DateTime.UtcNow.AddDays(-30))33 .ExecuteUpdateAsync(set => set34 .SetProperty(o => o.Status, "Archived")35 .SetProperty(o => o.ArchivedAt, DateTime.UtcNow), ct);36// Single UPDATE statement β no entity loading, no change tracker37// β Bypasses concurrency tokens β [Timestamp] NOT checked3839// ExecuteSqlRaw β raw UPDATE for complex expressions40var affected = await _dbContext.Database.ExecuteSqlRawAsync(41 "UPDATE Orders SET TotalAmount = TotalAmount * {0} WHERE CustomerId = {1}",42 discountFactor, customerId, ct);4344// Window function β LINQ can't express this, use FromSqlRaw45var ranked = await _dbContext.Database46 .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 Rank50 FROM Customers c51 JOIN Orders o ON o.CustomerId = c.Id52 GROUP BY c.Id, c.Name")53 .ToListAsync(ct);54// SqlQueryRaw<T> (EF Core 8+) β for arbitrary result shapes, not entity types5556// Stored procedure β output parameter57var 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;6263// Dapper alongside EF β shared connection64using var connection = _dbContext.Database.GetDbConnection();65if (connection.State != ConnectionState.Open)66 await connection.OpenAsync(ct);6768var results = await connection.QueryAsync<CustomerSummary>(69 @"SELECT c.Id, c.Name, COUNT(o.Id) AS OrderCount70 FROM Customers c71 LEFT JOIN Orders o ON o.CustomerId = c.Id72 GROUP BY c.Id, c.Name73 ORDER BY OrderCount DESC74 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()
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
1Window Function for Customer Ranking β LINQ Couldn't Express It
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.
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.
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
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.
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.
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
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; --
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.
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.