LINQ on in-memory collections (IEnumerable<T>) evaluates in C#. LINQ on EF Core (IQueryable<T>) translates your method chain into SQL and executes it in the database. The SQL translation is the engine of the entire EF Core data access layer in Part 4. Understanding when work happens in SQL (efficient) versus in C# (potentially loading thousands of rows into memory) is the single most important performance skill for ASP.NET Core developers.
IQueryable vs IEnumerable
// IQueryable — query is built but NOT executed until materialised
IQueryable<Post> query = _db.Posts // DbSet<Post> is IQueryable
.Where(p => p.IsPublished) // adds WHERE clause to SQL
.OrderByDescending(p => p.CreatedAt); // adds ORDER BY to SQL
// Still no SQL executed — query is just an expression tree
// Add more operators before executing
if (filterTag is not null)
query = query.Where(p => p.Tags.Any(t => t.Name == filterTag)); // adds JOIN
// Materialise — THIS is when the SQL executes
var result = await query
.Skip(skip)
.Take(pageSize)
.Select(p => new PostDto { Id = p.Id, Title = p.Title })
.ToListAsync();
// Resulting SQL (approximate):
// SELECT p.Id, p.Title
// FROM Posts p
// WHERE p.IsPublished = 1
// AND EXISTS (SELECT 1 FROM Tags WHERE PostId = p.Id AND Name = @tag)
// ORDER BY p.CreatedAt DESC
// OFFSET @skip ROWS FETCH NEXT @pageSize ROWS ONLY
Note:
IQueryable<T> extends IEnumerable<T> and adds an expression tree that query providers (like EF Core) can translate to SQL. When you call a LINQ operator on an IQueryable, EF Core adds to the expression tree rather than executing code. When you materialise (call ToListAsync(), FirstOrDefaultAsync(), etc.), EF Core translates the entire tree to one SQL statement and executes it. A single round-trip to the database — not one query per operator.Tip: Use
AsNoTracking() for read-only queries (controller GET endpoints, dashboard queries, report generation) — it tells EF Core not to register the returned entities in the change tracker, saving memory and CPU for tracking. A tracked entity is stored in the context’s identity map and snapshot for change detection. For a query returning 1,000 posts for a paginated list, that is 1,000 entity snapshots you never need. AsNoTracking() can cut query overhead by 30–50% for heavy read workloads.Warning: Client evaluation occurs when EF Core cannot translate a LINQ expression to SQL and falls back to loading data into memory, then filtering in C#. EF Core 3.0+ throws an exception for untranslatable expressions instead of silently falling back, which is the correct behaviour. Common causes: calling a custom C# method inside a
Where predicate, using a DateTime property in a way EF Core does not recognise, or referencing a local variable in a way that generates a parameterised query vs loading everything. Always verify the generated SQL in development with context.Database.Log or EF Core query logging.Include — Eager Loading Navigation Properties
// Without Include — accessing p.Author triggers a separate query per post (N+1!)
var posts = await _db.Posts.Where(p => p.IsPublished).ToListAsync();
foreach (var post in posts)
Console.WriteLine(post.Author.Name); // N separate SELECT queries!
// With Include — loads Author in the same SQL query (JOIN)
var posts2 = await _db.Posts
.Include(p => p.Author) // LEFT JOIN to Authors
.Include(p => p.Tags) // LEFT JOIN to Tags
.Where(p => p.IsPublished)
.ToListAsync();
// One SQL with JOINs — no N+1
// ThenInclude — load nested navigation properties
var posts3 = await _db.Posts
.Include(p => p.Comments)
.ThenInclude(c => c.Author) // load each comment's author too
.Where(p => p.IsPublished)
.ToListAsync();
AsNoTracking and Query Optimisation
// Read-only query — no change tracking needed
var posts = await _db.Posts
.AsNoTracking() // ✓ saves memory and CPU
.Where(p => p.IsPublished)
.OrderByDescending(p => p.CreatedAt)
.Select(p => new PostDto // project to DTO — only needed columns
{
Id = p.Id,
Title = p.Title,
Views = p.ViewCount,
})
.ToListAsync();
// Use AsNoTrackingWithIdentityResolution when using Include but not tracking
var posts2 = await _db.Posts
.AsNoTrackingWithIdentityResolution()
.Include(p => p.Tags)
.ToListAsync();
Common EF Core LINQ Mistakes
Mistake 1 — N+1 queries (accessing navigation properties without Include)
❌ Wrong — 1 query for posts + N queries for each author:
var posts = await _db.Posts.ToListAsync();
var names = posts.Select(p => p.Author.Name); // N lazy-loads!
✅ Correct — eager load with Include:
var posts = await _db.Posts.Include(p => p.Author).ToListAsync();
Mistake 2 — Calling AsEnumerable() / ToList() before filtering (loads entire table)
❌ Wrong — loads all rows into memory, then filters in C#:
var posts = await _db.Posts.ToListAsync(); // entire table in memory!
var filtered = posts.Where(p => p.IsPublished); // C# filter — too late
✅ Correct — filter before materialising:
var posts = await _db.Posts.Where(p => p.IsPublished).ToListAsync();