LINQ Deep Dive: Deferred Execution, Performance, and Real-World Patterns
Master LINQ beyond the basics. Covers deferred vs immediate execution, expression trees, GroupBy, joins, projections, performance traps, LINQ to EF gotchas, and interview-ready patterns.
Why LINQ Still Trips Up Senior Devs
LINQ looks simple. A few lambdas and you're done. But interviews and production bugs expose the gaps: deferred execution surprises, N+1 queries from lazy evaluation, IEnumerable vs IQueryable confusion, and GroupBy results nobody reads correctly.
This guide closes those gaps.
Deferred vs Immediate Execution
The most important concept in LINQ. Most operators are deferred ā they build a query description but don't run it until you iterate.
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// Deferred ā nothing executes here
var query = numbers.Where(x => x > 2);
numbers.Add(6); // mutate source AFTER defining query
foreach (var n in query)
Console.WriteLine(n); // prints 3, 4, 5, 6 ā sees the mutation!Deferred operators: Where, Select, OrderBy, GroupBy, Join, Take, Skip, SelectMany
Immediate operators (force execution now):
var list = query.ToList(); // executes, materializes
var array = query.ToArray();
var count = query.Count();
var first = query.First();
var any = query.Any();
var dict = query.ToDictionary(x => x);The Multiple-Enumeration Trap
// BAD ā executes the query twice
IEnumerable<Order> pending = orders.Where(o => o.Status == "Pending");
var count = pending.Count(); // iterates once
var firstId = pending.First().Id; // iterates again
// GOOD ā materialize once
var pendingList = orders.Where(o => o.Status == "Pending").ToList();
var count = pendingList.Count;
var firstId = pendingList[0].Id;IEnumerable vs IQueryable
This distinction is the source of most LINQ-to-EF performance bugs.
| | IEnumerable<T> | IQueryable<T> |
|---|---|---|
| Execution | In-memory (LINQ to Objects) | Remote (SQL, etc.) |
| Translation | Lambda delegates | Expression trees |
| Filter location | After data is loaded | In the database |
| Use case | In-memory collections | EF Core, OData |
// IQueryable ā WHERE runs in SQL
IQueryable<Product> query = dbContext.Products
.Where(p => p.Price > 100); // translates to SQL WHERE
// IEnumerable ā WHERE runs in C# AFTER loading all rows
IEnumerable<Product> query = dbContext.Products
.AsEnumerable() // loads EVERYTHING into memory
.Where(p => p.Price > 100); // filters in C#Never call .AsEnumerable() or .ToList() in the middle of a query unless you need client-side evaluation for something EF can't translate.
// BAD ā loads all orders, then filters in C#
var late = dbContext.Orders
.ToList() // 50,000 rows loaded
.Where(o => o.DeliveryDate < DateTime.UtcNow);
// GOOD ā filter in SQL
var late = dbContext.Orders
.Where(o => o.DeliveryDate < DateTime.UtcNow)
.ToList();Expression Trees (What Makes IQueryable Work)
When you write a lambda against IQueryable, C# doesn't compile it to a delegate ā it compiles it to an expression tree: a data structure that describes the code.
Expression<Func<Product, bool>> expr = p => p.Price > 100;
// This is NOT a function ā it's a tree you can inspect:
var body = (BinaryExpression)expr.Body;
var left = (MemberExpression)body.Left; // p.Price
var right = (ConstantExpression)body.Right; // 100
// EF Core walks this tree and emits SQL:
// WHERE [p].[Price] > 100You can build dynamic queries by composing expression trees:
public static IQueryable<T> WhereIf<T>(
this IQueryable<T> source,
bool condition,
Expression<Func<T, bool>> predicate)
=> condition ? source.Where(predicate) : source;
// Usage
var query = dbContext.Products
.WhereIf(minPrice.HasValue, p => p.Price >= minPrice!.Value)
.WhereIf(!string.IsNullOrEmpty(name), p => p.Name.Contains(name!));Select and Projections
Basic Projection
var names = products.Select(p => p.Name);
// Anonymous type projection
var summary = products.Select(p => new { p.Id, p.Name, p.Price });
// Named DTO projection
var dtos = products.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
});SelectMany ā Flattening Nested Collections
var orders = new List<Order>
{
new() { Items = new() { new() { Name = "Widget" }, new() { Name = "Gadget" } } },
new() { Items = new() { new() { Name = "Doohickey" } } }
};
// Flatten all items from all orders
var allItems = orders.SelectMany(o => o.Items);
// Widget, Gadget, Doohickey
// With correlation ā keep the parent
var withOrder = orders.SelectMany(
o => o.Items,
(order, item) => new { order.Id, item.Name });GroupBy
GroupBy is the operator most commonly misunderstood. It returns IEnumerable<IGrouping<TKey, TElement>> ā a sequence of groups.
var orders = new List<Order> { /* ... */ };
// Group by status, count per group
var byStatus = orders
.GroupBy(o => o.Status)
.Select(g => new
{
Status = g.Key,
Count = g.Count(),
Total = g.Sum(o => o.Amount)
});
foreach (var group in byStatus)
Console.WriteLine($"{group.Status}: {group.Count} orders, £{group.Total:N0}");GroupBy with Lookup
When you need fast key-based access to groups, use ToLookup (immediate, like a Dictionary of lists):
// Lookup is like a Dictionary<TKey, IEnumerable<TValue>>
var lookup = orders.ToLookup(o => o.CustomerId);
var customerOrders = lookup[customerId]; // O(1) access, no re-queryGroupBy in EF Core
// EF Core translates this to GROUP BY SQL
var summary = await dbContext.Orders
.GroupBy(o => o.CustomerId)
.Select(g => new
{
CustomerId = g.Key,
OrderCount = g.Count(),
TotalSpend = g.Sum(o => o.Total)
})
.ToListAsync();Joins
Join (Inner Join)
var result = customers.Join(
orders,
c => c.Id, // outer key
o => o.CustomerId, // inner key
(c, o) => new { c.Name, o.Amount }); // projectionGroupJoin (Left Outer Join equivalent)
var result = customers.GroupJoin(
orders,
c => c.Id,
o => o.CustomerId,
(c, customerOrders) => new
{
c.Name,
OrderCount = customerOrders.Count(),
TotalSpend = customerOrders.Sum(o => o.Amount)
});Prefer Navigation Properties in EF Core
In EF Core, use navigation properties instead of manual joins ā EF generates optimal SQL:
// Better than manual Join in EF Core
var result = await dbContext.Customers
.Include(c => c.Orders)
.Select(c => new
{
c.Name,
OrderCount = c.Orders.Count,
TotalSpend = c.Orders.Sum(o => o.Total)
})
.ToListAsync();Ordering
// Single key
var sorted = products.OrderBy(p => p.Price);
var desc = products.OrderByDescending(p => p.Price);
// Multiple keys
var multiSort = products
.OrderBy(p => p.Category)
.ThenBy(p => p.Price)
.ThenByDescending(p => p.Name);Partitioning: Take, Skip, Chunk
// Pagination
int page = 2, pageSize = 10;
var page2 = products
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize);
// Take while condition holds (deferred)
var cheapest = products
.OrderBy(p => p.Price)
.TakeWhile(p => p.Price < 50);
// Split into batches (immediate, .NET 6+)
var batches = products.Chunk(100); // IEnumerable<Product[]>
foreach (var batch in batches)
await ProcessBatchAsync(batch);Set Operations
var a = new[] { 1, 2, 3, 4 };
var b = new[] { 3, 4, 5, 6 };
var union = a.Union(b); // 1,2,3,4,5,6
var intersect = a.Intersect(b); // 3,4
var except = a.Except(b); // 1,2
var distinct = a.Concat(b).Distinct(); // same as unionAggregates
var numbers = new[] { 1, 2, 3, 4, 5 };
int sum = numbers.Sum();
int max = numbers.Max();
int min = numbers.Min();
double avg = numbers.Average();
int count = numbers.Count();
// Aggregate ā fold with custom accumulator
int product = numbers.Aggregate(1, (acc, x) => acc * x); // 120
// Running total
var running = numbers.Aggregate(
new List<int>(),
(acc, x) => { acc.Add(acc.LastOrDefault() + x); return acc; });
// 1, 3, 6, 10, 15First, Single, Any, All
// First / FirstOrDefault
var first = products.First(p => p.Price > 100); // throws if none
var firstOr = products.FirstOrDefault(p => p.Price > 100); // null if none
// Single ā expects exactly one match
var single = products.Single(p => p.Id == id); // throws if 0 or 2+
var singleOr= products.SingleOrDefault(p => p.Id == id); // null if 0, throws if 2+
// Existence checks ā use these instead of .Count() > 0
bool hasExpensive = products.Any(p => p.Price > 1000);
bool allInStock = products.All(p => p.Stock > 0);
bool noneExpired = !products.Any(p => p.ExpiryDate < DateTime.UtcNow);Never use .Count() > 0 to check existence ā Any() short-circuits and is O(1) for indexed sequences.
LINQ Performance Patterns
Materialize at the Right Time
// Define the query (deferred)
var query = dbContext.Products
.Where(p => p.IsActive)
.OrderBy(p => p.Name);
// Materialize once for multi-use
var products = await query.ToListAsync();
var count = products.Count; // no second DB call
var first = products.FirstOrDefault();Use Select to Avoid Over-Fetching
// BAD ā loads all columns, all relations
var products = await dbContext.Products.ToListAsync();
// GOOD ā only fetch what you need
var names = await dbContext.Products
.Select(p => new { p.Id, p.Name })
.ToListAsync();Avoid LINQ in Tight Loops
// BAD ā O(n²): FirstOrDefault scans on every iteration
foreach (var order in orders)
{
var customer = customers.FirstOrDefault(c => c.Id == order.CustomerId);
}
// GOOD ā O(n): Dictionary lookup
var customerMap = customers.ToDictionary(c => c.Id);
foreach (var order in orders)
{
var customer = customerMap.GetValueOrDefault(order.CustomerId);
}Common Interview Questions
Q: What is deferred execution and why does it matter? Deferred execution means a LINQ query is not evaluated when defined ā it's evaluated when iterated. This matters because the source can be mutated between definition and iteration, and multiple iterations re-execute the query (performance trap for EF queries).
Q: What's the difference between IEnumerable<T> and IQueryable<T>?
IEnumerable<T> uses delegates and runs in-process (LINQ to Objects). IQueryable<T> uses expression trees and translates to a remote query (SQL). Mixing them ā calling .AsEnumerable() early ā forces all data into memory before filtering.
Q: Why use Any() instead of Count() > 0?
Any() short-circuits at the first match. Count() always enumerates the full sequence. For large collections or EF queries, Any() produces SELECT TOP 1 instead of SELECT COUNT(*).
Q: What does SelectMany do?
It flattens a sequence of sequences into a single sequence. Equivalent to a SQL cross-apply or a nested foreach that yields each inner element.
Q: What is a LINQ expression tree?
A data structure that represents code as data. When a lambda targets IQueryable<T>, the C# compiler builds an expression tree instead of compiling to IL. Query providers (like EF Core) walk the tree to generate SQL.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.