LINQ is one of the best things about C#, and it's also where a lot of quietly expensive code hides in plain sight. None of the issues below are exotic — they're the same handful of patterns we flag in almost every code review, on almost every codebase.
- An
IEnumerable<T>query re-runs every time you enumerate it — iterating it twice can mean hitting the database, or redoing an expensive computation, twice. - Prefer the
.Countproperty over the.Count()method when both are available — on non-collection sequences,Count()has to walk the whole thing. - LINQ against
IQueryable(EF Core) can silently fall back to client-side evaluation if you call a method the provider can't translate — know the difference betweenIQueryableandIEnumerablein your signatures. - For genuinely hot paths, a plain
forloop orSpan<T>-based code isn't a betrayal of clean code — measure first, then decide.
Multiple enumeration is a silent perf bug
Methods that return IEnumerable<T> from a LINQ query don't return a materialized list — they return a recipe. Every foreach, every .Count(), every .Any() re-runs it.
public IEnumerable<Order> GetHighValueOrders() =>
_db.Orders.Where(o => o.Total > 1000); // deferred, not materialized
var orders = GetHighValueOrders();
Console.WriteLine(orders.Count()); // query #1 hits the database
foreach (var o in orders) { } // query #2 hits the database again
If a value is going to be enumerated more than once, materialize it once with .ToList() or .ToArray() and work with that. This matters most when the source is an IQueryable backed by a database — the second enumeration isn't just wasted CPU, it's a second round trip.
var orders = GetHighValueOrders().ToList(); // materialized once
Console.WriteLine(orders.Count); // in-memory, O(1)
foreach (var o in orders) { } // no second query
.Count vs Count() vs Any()
On any type implementing ICollection<T> (arrays, List<T>, most concrete collections), the .Count property is an O(1) field read. The LINQ Count() extension method checks for ICollection<T> first and takes the fast path when it can — but on a plain IEnumerable<T> without that interface, it has to enumerate the whole sequence to count it. The Roslyn analyzer CA1829 flags the easy cases; it's worth enabling if it isn't already.
Separately: checking whether there's anything in a sequence with .Count() == 0 forces a full count when all you need is one element. .Any() stops at the first match.
// Slower on IQueryable and non-collection IEnumerable
if (orders.Count() == 0) { }
// Faster -- stops at the first element
if (!orders.Any()) { }
Deferred execution and closures
Because a query doesn't run until it's enumerated, any variable it captures is read at enumeration time, not at the point the query was written. In a loop, this is a classic bug source.
var queries = new List<IEnumerable<Order>>();
foreach (var status in statuses)
{
// BUG: every query captures the loop variable by reference
queries.Add(_db.Orders.Where(o => o.Status == status));
}
// By the time these run, each closure reads whatever 'status' held at that point
// Fix: materialize inside the loop
foreach (var status in statuses)
{
var currentStatus = status;
queries.Add(_db.Orders.Where(o => o.Status == currentStatus).ToList());
}
C# has closed this gap for foreach loop variables specifically since C# 5 — each iteration gets its own variable. But the same trap still applies to any external mutable variable a query closes over, foreach or not, so it's worth knowing the underlying rule rather than just the foreach-specific fix.
IQueryable and client-side evaluation
Against EF Core, calling a C# method your database provider can't translate to SQL used to fail loudly in older versions and, in some cases, silently pulls the whole table into memory to finish the filter client-side in others, depending on provider and query shape. Either way, it's a trap: it works fine in a small dev database and falls over in production.
// Provider may not be able to translate a custom static method
var results = _db.Orders.Where(o => CustomBusinessRule(o)).ToList();
// Safer: translate to expressions the provider understands,
// or explicitly materialize first and filter in memory on purpose
var candidates = _db.Orders.Where(o => o.Status == OrderStatus.Pending).ToList();
var results = candidates.Where(o => CustomBusinessRule(o)).ToList();
BenchmarkDotNet takes twenty minutes to wire into a project and will tell you, with real numbers, whether a rewrite is worth the readability trade-off. We've seen “obvious” optimizations turn out to be noise more than once.When to drop to a for loop
For genuinely hot, allocation-sensitive paths — tight loops processing large in-memory buffers, not typical request handling — a plain for loop over a Span<T> avoids the iterator allocations and delegate indirection that LINQ introduces. This is the exception, not the default: reach for it after profiling shows LINQ is the actual bottleneck, not as a preemptive style choice.