Introduction
Optimize LINQ Queries.LINQ makes C# elegant — but also deceptively expensive.
The biggest trap isn’t syntax — it’s not knowing what actually runs in the database.
In real production systems (especially enterprise apps, CQRS, microservices), badly optimized LINQ can:
- generate inefficient SQL
- silently load entire tables
- create N+1 query explosions
- kill database performance under load
This guide goes deeper than typical tutorials.
You will learn:
- how LINQ really executes
- how to see the SQL generated
- how to enable logging and diagnostics
- how to find slow queries
- advanced optimization patterns used in real systems
PART 1 — How LINQ Really Works (Deep Dive)
Expression Trees vs Delegates
LINQ is not one thing — it behaves differently depending on the provider.
LINQ to Objects
var result = users.Where(u => u.Age > 18);This compiles to a delegate:
Func<User, bool>Runs in memory.
LINQ to Entities (EF Core)
var result = context.Users.Where(u => u.Age > 18);This becomes an Expression Tree:
Expression<Func<User, bool>>Which EF Core translates to SQL:
SELECT * FROM Users WHERE Age > 18
Visual: LINQ Translation Pipeline
5
Deferred Execution (Hidden Danger)
var query = context.Users.Where(u => u.IsActive);No SQL yet.
Execution happens here:
foreach (var user in query)
{
Console.WriteLine(user.Name);
}Multiple Enumeration Problem (Advanced)
var query = context.Users.Where(u => u.IsActive);
if (query.Any())
{
foreach (var user in query)
{
// SECOND execution
}
}
✔ Generates two SQL queries
Fix
var list = context.Users
.Where(u => u.IsActive)
.ToList();
if (list.Any())
{
foreach (var user in list)
{
}
}PART 2 — Seeing What SQL LINQ Generates
Optimize LINQ Queries.This is the most important skill.
If you don’t see SQL — you are guessing.
1. Get SQL Query with ToQueryString() (EF Core 5+)
var query = context.Users
.Where(u => u.Age > 30)
.OrderBy(u => u.Name);
var sql = query.ToQueryString();
Console.WriteLine(sql);✔ Output:
SELECT [u].[Id], [u].[Name], [u].[Age]
FROM [Users] AS [u]
WHERE [u].[Age] > 30
ORDER BY [u].[Name]2. Log SQL Queries Globally
In DbContext
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer("your-connection")
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging();
}3. ASP.NET Core Logging (Production Style)
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information);
});Visual: EF Logging Flow
6
4. Log Only Slow Queries
You can filter logs:
optionsBuilder.LogTo(log =>
{
if (log.Contains("Executed DbCommand"))
{
Console.WriteLine(log);
}
});Better approach: use interceptors
5. Use EF Core Interceptor to Measure Time
public class QueryInterceptor : DbCommandInterceptor
{
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
var sw = Stopwatch.StartNew();
var response = base.ReaderExecuting(command, eventData, result);
sw.Stop();
if (sw.ElapsedMilliseconds > 200)
{
Console.WriteLine($"SLOW QUERY ({sw.ElapsedMilliseconds} ms):");
Console.WriteLine(command.CommandText);
}
return response;
}
}Register:
optionsBuilder.AddInterceptors(new QueryInterceptor());PART 3 — Finding Slow Queries in SQL Server
1. Use SQL Server DMVs
SELECT TOP 10
qs.total_elapsed_time / qs.execution_count AS avg_time,
qs.execution_count,
qs.total_elapsed_time,
qt.text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
ORDER BY avg_time DESC;2. Use Query Store
Enable:
ALTER DATABASE YourDb SET QUERY_STORE = ON;Then analyze slow queries in SSMS. How to Optimize LINQ Queries.
Visual: Query Store
7
3. Use Execution Plans
SET STATISTICS IO ON;
SET STATISTICS TIME ON;Or use graphical plan in SSMS.
PART 4 — Core Optimization Techniques
1. Projection (Most Important)
❌ Bad:
var users = context.Users.ToList();✔ Good:
var users = context.Users
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name
})
.ToList();2. Avoid N+1 Queries
❌ Bad:
var users = context.Users.ToList();
foreach (var user in users)
{
var orders = context.Orders
.Where(o => o.UserId == user.Id)
.ToList();
}✔ Good:
var users = context.Users
.Include(u => u.Orders)
.ToList();Visual: N+1 Problem
6
3. AsNoTracking for Read Models
var users = context.Users
.AsNoTracking()
.ToList();✔ Up to 2x faster
4. Pagination
var users = context.Users
.OrderBy(u => u.Id)
.Skip(1000)
.Take(50)
.ToList();⚠ Problem: large OFFSET = slow
✔ Better (keyset pagination):
var users = context.Users
.Where(u => u.Id > lastId)
.OrderBy(u => u.Id)
.Take(50)
.ToList();5. Use Compiled Queries
static readonly Func<AppDbContext, int, List<User>> GetUsers =
EF.CompileQuery((AppDbContext ctx, int age) =>
ctx.Users.Where(u => u.Age > age).ToList());6. Avoid Client-Side Evaluation
❌ Bad:
var users = context.Users
.Where(u => CustomMethod(u.Name))
.ToList();✔ EF cannot translate → loads ALL data
7. Use Index-Friendly Queries
❌ Bad:
.Where(u => u.Name.ToLower() == "john")✔ Good:
.Where(u => u.Name == "John")Visual: Index Usage
6
PART 5 — Advanced Patterns (Real Systems)
1. Split Queries (EF Core)
var users = context.Users
.Include(u => u.Orders)
.AsSplitQuery()
.ToList();✔ Avoids huge JOIN explosion
2. Batch Queries
Instead of:
foreach (var id in ids)
{
context.Users.Find(id);
}✔ Use:
var users = context.Users
.Where(u => ids.Contains(u.Id))
.ToList();3. Raw SQL for Critical Paths
var users = context.Users
.FromSqlRaw("SELECT * FROM Users WHERE Age > {0}", age)
.ToList();4. Caching
var users = cache.GetOrCreate("users", entry =>
{
return context.Users.AsNoTracking().ToList();
});5. Read Model (CQRS Optimization)
Instead of complex joins:
✔ Create denormalized table:
UserOrderSummaryThen query:
context.UserOrderSummaries.ToList();PART 6 — Real Production Debugging Scenario
Problem
Slow endpoint:
var users = context.Users
.Where(u => u.IsActive)
.Select(u => new
{
u.Name,
Orders = context.Orders
.Where(o => o.UserId == u.Id)
.ToList()
})
.ToList();Step 1 — Inspect SQL
Use:
query.ToQueryString();Step 2 — Enable Logging
.LogTo(Console.WriteLine)Step 3 — Detect N+1
Logs show:
var users = context.Users
.Include(u => u.Orders)
.Select(u => new
{
u.Name,
u.Orders
})
.ToList();Step 4 — Fix
var users = context.Users
.Include(u => u.Orders)
.Select(u => new
{
u.Name,
u.Orders
})
.ToList();Result
- 100 queries → 1 query
- response time: 2.3s → 120ms
PART 7 — Checklist for LINQ Optimization
✔ Always inspect SQL
✔ Use projection
✔ Avoid ToList() early
✔ Use AsNoTracking()
✔ Avoid N+1
✔ Add indexes
✔ Use pagination
✔ log queries
✔ profile database
✔ use compiled queries
Conclusion
LINQ optimization is not about syntax — it’s about understanding the full pipeline:
C# → Expression Tree → SQL → Execution Plan → Indexes
Once you master:
- how to see SQL
- how to measure performance
- how to rewrite queries
you move from “developer” to performance engineer.
Optimize LINQ Queries… more LINQ Optimization Cases: IQueryable vs IEnumerable, Hidden Memory Problems and Real EF Core Pitfalls
One of the most important topics in LINQ performance is understanding the difference between IQueryable and IEnumerable.
Many slow LINQ queries are not slow because LINQ is bad. They are slow because the query accidentally moves from the database to application memory.
IQueryable vs IEnumerable
This difference is critical.
IQueryable
IQueryable represents a query that can still be translated by a provider, for example Entity Framework Core.
IQueryable<User> query = context.Users
.Where(u => u.IsActive);At this point, no SQL has been executed yet.
EF Core can still translate this into SQL:
SELECT *<br>FROM Users<br>WHERE IsActive = 1This means filtering happens inside the database.
That is usually what you want.
IEnumerable
IEnumerable represents an in-memory sequence. How to Optimize LINQ Queries.
SELECT *
FROM Users
WHERE IsActive = 1This looks similar, but it can become dangerous depending on where the conversion happens.
Once data is materialized with ToList(), AsEnumerable(), or similar methods, the rest of the query is executed in memory.
Example:
var users = context.Users
.AsEnumerable()
.Where(u => u.Age > 30)
.ToList();This may load many rows from the database first, then filter them in C#.
Bad.
Bad Example: Accidental In-Memory Filtering
var users = context.Users
.AsEnumerable()
.Where(u => u.Age > 30)
.ToList();The problem is AsEnumerable().
How to Optimize LINQ Queries, after that line, EF Core stops translating the query to SQL. The Where runs in memory.
Better:
var users = context.Users
.Where(u => u.Age > 30)
.ToList();Now the database does the filtering.
Rule
Use IQueryable when building database queries.
Use IEnumerable only after the data is already intentionally loaded into memory.
Example: Repository Returning IEnumerable — Hidden Performance Bug
This is a very common enterprise mistake.
Bad repository:
public IEnumerable<User> GetUsers()
{
return _context.Users;
}Optimize LINQ Queries, usage:
var users = repository.GetUsers()
.Where(u => u.IsActive)
.Take(50)
.ToList();
This looks fine, but depending on implementation, the filtering may happen in memory.
Better:
public IQueryable<User> QueryUsers()
{
return _context.Users;
}Usage:
var users = repository.QueryUsers()
.Where(u => u.IsActive)
.Take(50)
.ToList();Now EF Core can translate the full query.
However, exposing IQueryable from repositories is also controversial because it leaks database query logic outside the repository.
A better compromise is often a specific query method, this is how to optimize LINQ queries:
public Task<List<UserDto>> GetActiveUsersAsync(int page, int pageSize)
{
return _context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.OrderBy(u => u.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Email = u.Email
})
.ToListAsync();
}This keeps performance and encapsulation.
Case: AsEnumerable Should Be Used Only Intentionally
Sometimes AsEnumerable() is useful.
For example, when part of the logic cannot be translated to SQL:
var users = await context.Users
.Where(u => u.IsActive)
.Select(u => new
{
u.Id,
u.Name
})
.ToListAsync();
var result = users
.Where(u => MyCustomCSharpMethod(u.Name))
.ToList();This is acceptable because the database query is already limited.
Bad:
var result = context.Users
.AsEnumerable()
.Where(u => MyCustomCSharpMethod(u.Name))
.ToList();This can load the entire Users table.
Case: Count() vs Any()
Bad:
if (context.Users.Count(u => u.IsActive) > 0)
{
// users exist<br>
}Better:
if (await context.Users.AnyAsync(u => u.IsActive))
{
// users exist
}Any() can stop after finding the first matching row. Count() has to count all matching rows.
Case: FirstOrDefault vs SingleOrDefault
Use FirstOrDefault() when you only need the first matching record.
var user = await context.Users
.FirstOrDefaultAsync(u => u.Email == email);Use SingleOrDefault() only when you want to enforce that there must be zero or one matching row.
var user = await context.Users
.SingleOrDefaultAsync(u => u.Email == email);SingleOrDefault() may require extra work because it needs to check whether more than one row exists.
For unique columns like Email, prefer a unique index in the database.
Case: Contains and Large IN Lists
This is common:
var users = await context.Users
.Where(u => ids.Contains(u.Id))
.ToListAsync();EF Core usually translates this to SQL IN.
WHERE Id IN (1, 2, 3, 4)This is fine for small and medium lists.
But for thousands of IDs, it can become slow.
Better options:
- use a temporary table
- use table-valued parameters in SQL Server
- batch the query
- redesign the flow
Example batching:
var ids = new List<int> { 1, 2, 3, 4 };
var result = new List<User>();
foreach (var batch in ids.Chunk(500))
{
var users = await context.Users
.Where(u => batch.Contains(u.Id))
.ToListAsync();
result.AddRange(users);
}Case: StartsWith vs Contains
This can matter for indexes.
Better:
var users = await context.Users
.Where(u => u.Name.StartsWith("John"))
.ToListAsync();Potentially worse:
var users = await context.Users
.Where(u => u.Name.Contains("John"))
.ToListAsync();StartsWith("John") can often use an index.
Contains("John") usually becomes:
LIKE '%John%'That pattern often cannot use a normal index efficiently.
Case: Avoid Functions on Indexed Columns
Bad:
var users = await context.Users
.Where(u => u.Email.ToLower() == email.ToLower())
.ToListAsync();This may prevent efficient index usage.
Better:
var normalizedEmail = email.ToUpper();
var users = await context.Users
.Where(u => u.NormalizedEmail == normalizedEmail)
.ToListAsync();Store normalized values in separate indexed columns when search performance matters.
Case: OrderBy Before Where
Bad:
var users = await context.Users
.OrderBy(u => u.Name)
.Where(u => u.IsActive)
.Take(50)
.ToListAsync();Better:
var users = await context.Users
.Where(u => u.IsActive)
.OrderBy(u => u.Name)
.Take(50)
.ToListAsync();Filter first. Sort later.
Sorting fewer rows is cheaper.
Case: Select Before Include
This is an important EF Core case.
Bad:
var users = await context.Users
.Include(u => u.Orders)
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name
})
.ToListAsync();If you project only Id and Name, you probably do not need Include.
Better:
var users = await context.Users
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name
})
.ToListAsync();Use Include when loading entities with navigation properties.
Use Select when creating DTOs.
Case: Include Explosion
Bad:
var users = await context.Users
.Include(u => u.Orders)
.Include(u => u.Invoices)
.Include(u => u.SupportTickets)
.ToListAsync();This can produce a huge JOIN result.
Better:
var users = await context.Users
.Include(u => u.Orders)
.Include(u => u.Invoices)
.Include(u => u.SupportTickets)
.AsSplitQuery()
.ToListAsync();AsSplitQuery() can reduce Cartesian explosion by executing multiple simpler SQL queries.
Case: Projection Instead of Include
Often this is even better:
var users = await context.Users
.AsNoTracking()
.Select(u => new UserSummaryDto
{
Id = u.Id,
Name = u.Name,
OrdersCount = u.Orders.Count,
LastOrderDate = u.Orders
.OrderByDescending(o => o.CreatedAt)
.Select(o => o.CreatedAt)
.FirstOrDefault()
})
.ToListAsync();This avoids loading full child collections.
You get only the data needed by the screen or API.
Case: Bad Select with Full Entity
Bad:
var users = await context.Users
.Select(u => new
{
User = u,
OrderCount = u.Orders.Count
})
.ToListAsync();This may still load too much user data.
Better:
var users = await context.Users
.Select(u => new UserSummaryDto
{
Id = u.Id,
Name = u.Name,
OrderCount = u.Orders.Count
})
.ToListAsync();Never project full entities unless you really need them.
Case: Tracking vs No Tracking
Bad for read-only endpoints:
var users = await context.Users
.Where(u => u.IsActive)
.ToListAsync();Better:
var users = await context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.ToListAsync();EF Core tracking is useful when you want to update entities.
For read-only APIs, dashboards, reports, and CQRS read models, use AsNoTracking().
Case: AsNoTrackingWithIdentityResolution
Sometimes you want no tracking but still want EF Core to avoid duplicate entity instances.
var users = await context.Users
.AsNoTrackingWithIdentityResolution()
.Include(u => u.Orders)
.ToListAsync();Use it when:
- you read complex object graphs
- you do not want change tracking
- but you want identity resolution
For simple DTO queries, prefer normal AsNoTracking().
Case: Pagination with Skip/Take
Basic pagination:
var page = await context.Users
.OrderBy(u => u.Id)
.Skip(1000)
.Take(50)
.ToListAsync();This is okay for small offsets.
For large tables, this becomes slower because SQL Server still has to walk through skipped rows.
Better: keyset pagination.
var page = await context.Users
.Where(u => u.Id > lastSeenId)
.OrderBy(u => u.Id)
.Take(50)
.ToListAsync();This is often much faster for infinite scroll, APIs, exports, and background jobs.
Case: Do Not Return IQueryable from API Controllers
Bad:
[HttpGet]
public IQueryable<User> GetUsers()
{
return _context.Users;
}This leaks query logic and can create unpredictable behavior.
How to Optimize LINQ Queries:
[HttpGet]
public async Task<List<UserDto>> GetUsers()
{
return await _context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.OrderBy(u => u.Name)
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name
})
.ToListAsync();
}API endpoints should return controlled DTOs, not open query objects.
Case: Dynamic Filters with IQueryable
This is one of the best uses of IQueryable.
IQueryable<User> query = context.Users.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
{
query = query.Where(u => u.Name.Contains(search));
}
if (isActive.HasValue)
{
query = query.Where(u => u.IsActive == isActive.Value);
}
if (minAge.HasValue)
{
query = query.Where(u => u.Age >= minAge.Value);
}
var result = await query
.OrderBy(u => u.Name)
.Take(100)
.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Age = u.Age
})
.ToListAsync();This is good because the query is built step by step, but executed only once at the end.
Case: Wrong Dynamic Filtering with IEnumerable
Bad:
IEnumerable<User> users = await context.Users.ToListAsync();
if (!string.IsNullOrWhiteSpace(search))
{
users = users.Where(u => u.Name.Contains(search));
}
if (isActive.HasValue)
{
users = users.Where(u => u.IsActive == isActive.Value);
}
var result = users.ToList();This loads all users first.
For large tables, this is a serious performance bug.
Case: GroupBy in Database vs Memory
Bad:
var result = context.Orders
.ToList()
.GroupBy(o => o.CustomerId)
.Select(g => new
{
CustomerId = g.Key,
Count = g.Count()
})
.ToList();How to Optimize LINQ Queries:
var result = await context.Orders
.GroupBy(o => o.CustomerId)
.Select(g => new
{
CustomerId = g.Key,
Count = g.Count()
})
.ToListAsync();The second version lets SQL Server perform grouping.
Case: Sum, Min, Max, Average
Bad:
var orders = await context.Orders.ToListAsync();
var total = orders.Sum(o => o.TotalValue);Better:
var total = await context.Orders.SumAsync(o => o.TotalValue);Let the database aggregate.
Case: Date Filtering
Bad:
var orders = await context.Orders
.Where(o => o.CreatedAt.Date == selectedDate.Date)
.ToListAsync();Calling .Date on the column may hurt index usage.
Better:
var start = selectedDate.Date;
var end = start.AddDays(1);
var orders = await context.Orders
.Where(o => o.CreatedAt >= start && o.CreatedAt < end)
.ToListAsync();This is much more index-friendly.
Case: Avoid Lazy Loading in APIs
Lazy loading can cause hidden queries.
Bad:
var users = await context.Users.ToListAsync();
foreach (var user in users)
{
Console.WriteLine(user.Orders.Count);
}This can trigger one query per user.
Better:
var users = await context.Users
.Select(u => new UserSummaryDto
{
Id = u.Id,
Name = u.Name,
OrdersCount = u.Orders.Count
})
.ToListAsync();This creates one controlled SQL query.
Case: Use TagWith to Identify LINQ Queries in SQL Logs
Very useful in production diagnostics.
var users = await context.Users
.TagWith("UsersController.GetActiveUsers")
.Where(u => u.IsActive)
.ToListAsync();Generated SQL contains a comment:
-- UsersController.GetActiveUsers
SELECT ...FROM Users WHERE IsActive = 1This helps you connect slow SQL queries back to C# code.
Case: Get Generated SQL with ToQueryString
var query = context.Users.Where(u => u.IsActive);
var sql = query.ToQueryString();
var users = await query.ToListAsync();Important: ToQueryString() works before execution.
Good:
var query = context.Users.Where(u => u.IsActive);
var sql = query.ToQueryString();
var users = await query.ToListAsync();Bad:
var users = await context.Users
.Where(u => u.IsActive)
.ToListAsync();
// Too late — this is already executed.Case: Add EF Core SQL Logging
In Program.cs:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString)
.EnableDetailedErrors()
.LogTo(Console.WriteLine, LogLevel.Information);
});For development only:
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString)
.EnableSensitiveDataLogging()
.EnableDetailedErrors()
.LogTo(Console.WriteLine, LogLevel.Information);
});EnableSensitiveDataLogging() can log parameter values, so do not enable it blindly in production.
Case: Log Only Database Commands
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString)
.LogTo(
Console.WriteLine,
new[] { DbLoggerCategory.Database.Command.Name },
LogLevel.Information);
});This keeps logs cleaner.
Case: Detect Slow Queries with Interceptor
public sealed class SlowQueryInterceptor : DbCommandInterceptor
{
private readonly ILogger<SlowQueryInterceptor> _logger;
public SlowQueryInterceptor(ILogger<SlowQueryInterceptor> logger)
{
_logger = logger;
}
public override async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
var stopwatch = Stopwatch.StartNew();
var response = await base.ReaderExecutingAsync(
command,
eventData,
result,
cancellationToken);
stopwatch.Stop();
if (stopwatch.ElapsedMilliseconds > 500)
{
_logger.LogWarning(
"Slow SQL query detected: {ElapsedMilliseconds} ms. SQL: {Sql}",
stopwatch.ElapsedMilliseconds,
command.CommandText);
}
return response;
}
}Register it:
builder.Services.AddScoped<SlowQueryInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
var interceptor = sp.GetRequiredService<SlowQueryInterceptor>();
options.UseSqlServer(connectionString)
.AddInterceptors(interceptor);
});Important Note About Interceptor Timing
The example above is simplified.
For precise timing, you usually need to start measuring before execution and stop after execution. In production, you can use EF Core diagnostics, logging, MiniProfiler, Application Insights, OpenTelemetry, or SQL Server Query Store.
Case: Use SQL Server Query Store
For production systems, EF logging is not enough.
Use SQL Server Query Store to find:
- most expensive queries
- queries with high CPU
- queries with high duration
- regressed execution plans
- queries that became slower after deployment
Basic enable command:
ALTER DATABASE YourDatabase<br>SET QUERY_STORE = ON;Then in SQL Server Management Studio:
Database → Query Store → Top Resource Consuming Queries
This is one of the best tools for finding real slow queries.
Case: Use SET STATISTICS IO and TIME
When investigating one SQL query:
SET STATISTICS IO ON;
SET STATISTICS TIME ON;Then run the SQL generated by EF Core.
Look for:
- logical reads
- CPU time
- elapsed time
- scans
- missing indexes
High logical reads usually mean SQL Server is reading too much data.
Case: Do Not Optimize LINQ Without Checking SQL
This is the most important rule.
Bad workflow:
LINQ looks nice → assume it is fast
Good workflow:
LINQ query
↓
ToQueryString()
↓
SQL logs
↓
Execution plan
↓
Indexes
↓
Rewrite query
↓
Measure again
LINQ optimization without looking at SQL is guessing.
Practical Checklist
Before accepting a LINQ query in production, ask:
- Does it execute in the database or memory?
- Is it still
IQueryablebeforeToListAsync()? - Is there any accidental
AsEnumerable()? - Does it use
Selectprojection? - Does it avoid unnecessary
Include? - Does it use
AsNoTracking()for read-only data? - Does it avoid N+1 queries?
- Can I see generated SQL with
ToQueryString()? - Is the query visible in logs?
- Is it covered by proper indexes?
- Does pagination use
Skip/Takeor keyset pagination? - Are string/date filters index-friendly?
- Is the query measured in SQL Server Query Store?
Short Summary
IQueryable means:
Query is still being built.
Database provider can translate it.
Filtering can happen in SQL.
IEnumerable means:
Data is being processed as a .NET sequence.
Filtering may happen in memory.
Dangerous before reducing data size.
The golden rule:
Keep the query as IQueryable as long as possible.
Materialize with ToListAsync only at the end.
This one rule prevents a huge number of LINQ performance problems.
Source
Example project with no dependencies (SQLite) :
https://github.com/rafalkukuczka/LinqOptimizationDemo
https://github.com/rafalkukuczka/LinqOptimizationDemoSqlServer
References
https://pl.wikipedia.org/wiki/LINQ
https://blog.elmah.io/visualizing-linq-queries-with-linqpad-boost-your-ef-core-debugging
https://www.linqpad.net/linqpad8.aspx
More Info
We implement this in real production systems.
If you need help → contact us