I’ve been there. You’re staring at a dashboard in your multi-tenant app, and it’s crawling. The culprit is a query filtering across tens of thousands of records. Your first instinct? “LINQ is too slow. I’ll just write the raw SQL myself.”
It’s a tempting thought. But dropping down to raw SQL isn’t a simple performance switch. Get it wrong, and you could introduce a security hole or, ironically, make performance even worse. This exact scenario burned me once in production, and the fix wasn’t what I expected.
Let’s break down when to stick with LINQ and when (and how) to safely write your own SQL in EF Core.
First, The Big One: Security
If you take one thing away from this post, let it be this: never concatenate user input into a SQL string. SQL injection isn’t a textbook problem; it’s a real threat that I’ve seen take down production systems.
It’s easy to make this mistake, especially with C#’s slick string interpolation. This looks safe, right?
// DANGER: NEVER DO THIS.
var userId = Request.Query["userId"]; // e.g., "123"
var sql = $"SELECT * FROM Users WHERE Id = {userId}";
var users = await context.Users.FromSqlRaw(sql).ToListAsync();
This code is a wide-open door. An attacker can send 123; DROP TABLE Users; --
as the userId
, and boom, your user table is gone.
The critical gotcha is that standard C# string interpolation ($"..."
) does not protect you. It just builds a string. The magic happens when you use EF Core’s specific methods.
Here’s the right way to do it:
// CORRECT: EF Core handles parameterization for you.
var userId = Request.Query["userId"];
var users = await context.Users
.FromSqlInterpolated($"SELECT * FROM Users WHERE Id = {userId}")
.ToListAsync();
Notice the method changed from FromSqlRaw
to FromSqlInterpolated
. Behind the scenes, EF Core isn’t just smashing strings together. It generates a parameterized query like SELECT * FROM Users WHERE Id = @p0
and sends the userId
value separately. This completely neutralizes SQL injection attacks.
Okay, But Is Raw SQL Faster? I Ran the Numbers.
So, assuming we’re using safe, parameterized queries, is raw SQL still a performance win? I set up a BenchmarkDotNet test to find out. The test runs 1,000 individual lookups against a SQL Server database with 10,000 records.
The results were… weird.
Method | Mean | Notes |
---|---|---|
BulkLinqApproach | 20.58 ms | Winner: One trip to the DB |
ComplexAnalyticalQuery | 19.77 ms | Raw SQL for complex logic shines |
LinqApproach | 387.21 ms | Baseline for N+1 lookups |
ParameterizedRawSql | 421.91 ms | Slightly slower than LINQ! |
StringConcatenationRawSql | 1,043.84 ms | Loser: 2.7x slower, and insecure |
Here’s what this data really tells us:
- String Concatenation is Genuinely Awful: At over 1 second, it was by far the slowest. Why? SQL Server has to compile a new query plan for every single unique query string. This pollutes the plan cache and adds massive overhead.
- LINQ is Really, Really Good: For simple lookups, modern EF Core’s LINQ-to-SQL translation is highly optimized. It was actually slightly faster than the parameterized raw SQL equivalent. The overhead difference is negligible.
- The Real Enemy is Round Trips: Look at the
BulkLinqApproach
. It fetched all 1,000 records in a single query and blew everything else away at just 20ms. The N+1 problem (one query to get the list, then N queries to get details) is almost always a bigger performance killer than how the query is written.
So, the whole “is LINQ slower than raw SQL?” debate is often asking the wrong question. The right question is, “Am I making too many trips to the database?”
When I Actually Drop Down to Raw SQL
After seeing those numbers, my default is to stick with LINQ. It’s type-safe, easier to compose, and its performance is excellent. But there are a few specific cases where I’ll reach for FromSqlInterpolated
or ExecuteSqlInterpolatedAsync
.
1. For Complex Analytics LINQ Can’t Handle
Sometimes you need Common Table Expressions (CTEs), window functions (ROW_NUMBER()
), or other advanced SQL features that LINQ just can’t generate cleanly.
// Good use case: Get monthly sales stats for a tenant
var monthlyStats = await context.Database
.SqlQuery<MonthlyStatsResult>($@"
WITH MonthlySales AS (
SELECT
EOMONTH(OrderDate) as MonthEnd,
SUM(TotalAmount) as Revenue
FROM Orders
WHERE CustomerId = {tenantId}
GROUP BY EOMONTH(OrderDate)
)
SELECT MonthEnd, Revenue FROM MonthlySales
WHERE Revenue > {minRevenue}
ORDER BY MonthEnd DESC")
.ToListAsync();
Trying to write this in LINQ would be a painful, unreadable mess, if it’s even possible. Raw SQL is the clear winner here.
2. For Bulk Updates and Deletes
If you need to update thousands of rows, loading them all into EF Core’s change tracker is a huge waste of memory and CPU. A single UPDATE
or DELETE
statement is far more efficient.
// Perfect for bulk operations
var multiplier = 1.10m; // 10% price increase
var categoryId = 5;
var affectedRows = await context.Database
.ExecuteSqlInterpolatedAsync($@"
UPDATE Products
SET Price = Price * {multiplier}
WHERE CategoryId = {categoryId}");
This is a fire-and-forget operation that runs entirely on the database server. It’s fast and efficient.
3. Calling Stored Procedures
If your database logic lives in stored procedures, ExecuteSqlInterpolatedAsync
is the direct path to calling them.
var result = await context.Database
.ExecuteSqlInterpolatedAsync($@"
EXEC GenerateMonthlyReport
@TenantId = {tenantId},
@StartDate = {startDate}");
The Final Takeaway: My Playbook
So, what’s the verdict? Here’s the simple playbook I follow in my own projects.
When to use LINQ (90% of the time): For all standard create, read, update, and delete operations. It’s type-safe, maintainable, and the performance is fantastic. Before you blame LINQ for being slow, make sure you aren’t creating an N+1 query.
When to use Parameterized Raw SQL (
FromSqlInterpolated
): When you need to run a complexSELECT
query that LINQ can’t express well (like queries with CTEs or window functions). It gives you the full power of SQL without sacrificing security.When to use
ExecuteSqlInterpolated
: For bulkUPDATE
,DELETE
, orINSERT
operations that would be inefficient to handle through the change tracker, and for calling stored procedures.When to use String Concatenated SQL: Never. Just don’t. It’s insecure and, as the benchmarks show, often slower.
The real performance wins don’t come from micro-optimizing LINQ vs. raw SQL. They come from designing your queries to reduce round trips and letting the database do what it does best: work with large sets of data.
References
- Raw SQL Queries - EF Core Documentation
- FromSql and FromSqlRaw - Microsoft Docs
- ExecuteUpdate and ExecuteDelete - Microsoft Docs