The capstone of this module: everything you've learned about LINQ, now examined for what it actually costs.
Every lesson in this module has built toward the same quiet promise: LINQ reads clearly and hides the looping. But "hides" is doing real work in that sentence — the looping is still happening, allocations still occur, and every operator you've learned still costs real CPU cycles and real memory. This capstone lesson pulls together deferred execution, immediate execution, and method syntax into one final, practical question: how do you write LINQ that's both readable and genuinely efficient?
This lesson covers multiple enumeration pitfalls, Count() vs the .Count property, unnecessary ToList() calls, ordering operators to filter before projecting or sorting, and — the most important judgment call — when LINQ's readability is worth its cost versus when a hand-written loop genuinely serves you better.
LINQ performance is about recognizing the handful of patterns where writing a query the "obvious" way quietly does far more work than necessary — re-running the same filter twice, allocating a list nobody needed, or sorting ten thousand items before throwing away all but three of them.
Every LINQ operator ultimately reduces to ordinary work: iterator objects being allocated, MoveNext() being called, delegates being invoked, and — whenever a forcing operator like ToList() runs — memory being allocated for a new collection. None of this is free, and none of it is exotic; it's the exact same cost a hand-written loop would pay for equivalent work. The performance patterns in this lesson are entirely about avoiding redundant work — the same items enumerated more times than needed, the same allocation happening more times than needed — not about LINQ carrying some hidden, mysterious tax.
LINQ's biggest strength — hiding the looping mechanics behind clean, declarative method calls — is also exactly what makes its costs easy to miss. A hand-written loop makes every pass over the data visually obvious; a chain of LINQ calls can quietly hide a second (or third) full pass behind what looks like a single, simple line:
// Looks like ONE operation. Is actually TWO full passes over expensiveQuery.
var expensiveQuery = products.Where(p => ExpensiveCheck(p)); // deferred — nothing has run yet
if (expensiveQuery.Any()) // pass #1 — enumerates until first match
{
foreach (var p in expensiveQuery) // pass #2 — enumerates the WHOLE thing again
Console.WriteLine(p.Name);
}// ONE pass, explicitly, because it was forced exactly once
var results = products.Where(p => ExpensiveCheck(p)).ToList();
if (results.Count > 0)
{
foreach (var p in results)
Console.WriteLine(p.Name);
}This entire lesson exists because LINQ's readability makes it genuinely easy to accidentally write the first version without noticing — and because a handful of specific, well-known patterns account for the overwhelming majority of real-world LINQ performance problems. Learning to recognize them takes minutes; the payoff lasts for every query you write afterward.
This is the same gotcha the Deferred Execution lesson introduced, now framed as a performance problem: every additional enumeration of a deferred query re-runs its entire chain, from scratch, against the source.
public record Product(int Id, string Name, decimal Price, int Stock);
IEnumerable<Product> GetDiscountedProducts(List<Product> catalog)
=> catalog.Where(p => p.Price > 100).Select(p => p with { Price = p.Price * 0.9m });
var discounted = GetDiscountedProducts(products);
// Each of these lines re-runs the ENTIRE Where + Select chain, independently
Console.WriteLine($"Count: {discounted.Count()}"); // pass #1
Console.WriteLine($"Total: {discounted.Sum(p => p.Price)}"); // pass #2
foreach (var p in discounted) Console.WriteLine(p.Name); // pass #3// Force once, reuse the materialized result for everything after
var discounted = GetDiscountedProducts(products).ToList();
Console.WriteLine($"Count: {discounted.Count}"); // reads the stored list
Console.WriteLine($"Total: {discounted.Sum(p => p.Price)}"); // one pass over the list, cheap
foreach (var p in discounted) Console.WriteLine(p.Name); // reads the stored listThree full re-executions of the filter and projection became one — this is exactly the ToList() pattern the Immediate Execution lesson taught, applied here specifically as a performance fix rather than a correctness one.
Count() the Method vs .Count/.Length the PropertyEvery collection type carries a meaningfully different cost here. List<T>.Count and T[].Length are properties — the count is already tracked internally and reading it is instant, O(1), no matter how many items are in the collection. Enumerable.Count() is a LINQ method — and while it's smart enough to check whether its source implements ICollection<T> ("if it already knows its own count, just ask it" — an O(1) shortcut), that optimization is only available when the source is still a concrete collection type. The moment a deferred operator like Where sits in front of it, that shortcut is gone — Count() has to fall back to walking every single item, an O(n) operation:
List<Product> products = GetLargeProductList(); // say, 50,000 items
int a = products.Count; // O(1) — property, instant
int b = products.Count(); // O(1) too — Enumerable.Count() detects ICollection<T> and shortcuts
int c = products.Where(p => p.Stock > 0).Count(); // O(n) — Where's result ISN'T a List<T> anymore; the shortcut is goneThe lesson here isn't "never call Count()" — it's to recognize that the O(1) shortcut silently disappears the instant any deferred operator comes before it in the chain. When you already hold a concrete List<T> or array and just need its size, reach for .Count/.Length directly rather than habitually calling Count().
ToList() Calls Mid-ChainAlready covered as a Common Mistake in the Immediate Execution lesson, worth restating here as a genuine performance cost, not just a stylistic one: every ToList() call allocates a new internal array and copies every item into it. Calling it more than once in a single chain multiplies that allocation cost for zero benefit.
// TWO allocations, TWO full copies — the middle ToList() serves no purpose
var result = products
.Where(p => p.Stock > 0)
.ToList() // wasted allocation #1
.Where(p => p.Price < 100)
.ToList(); // allocation #2
// ONE allocation, at the very end, once everything is fully chained
var result = products
.Where(p => p.Stock > 0)
.Where(p => p.Price < 100)
.ToList();Where can shrink a sequence before the next, potentially more expensive operator has to touch it. Reversing the order does needless extra work:
// Sorts ALL 50,000 products (an O(n log n) pass), THEN throws most of them away
var slow = products
.OrderBy(p => p.Price)
.Where(p => p.Category == "Electronics"); // only ~5,000 of the 50,000 survive — too late, already sorted everything
// Filters down to ~5,000 FIRST, so the sort only has to handle those
var fast = products
.Where(p => p.Category == "Electronics")
.OrderBy(p => p.Price);The same logic applies to projecting: running Select before Where transforms every item, including the ones about to be discarded — wasted work if the projection does anything nontrivial. As a general rule, put Where as early in a chain as the query's logic allows, so every operator after it has less data to process.
One more common pitfall, tying back to the very first lesson of this module — using Where(...).First() instead of First(predicate) directly:
// Builds a whole deferred filter, THEN asks for the first match through it
var product = products.Where(p => p.Id == 42).First();
// First(predicate) stops the instant it finds a match — no intermediate Where needed
var product = products.First(p => p.Id == 42);Code → Meaning → Result: Both versions return the identical product. But First(predicate) is implemented to short-circuit — it stops enumerating the moment a match is found, exactly like Any(predicate) does — while Where(...).First() builds an extra iterator layer for no real benefit. This is a small cost individually, but it's a habit worth building since it appears constantly in real code.
An e-commerce catalog search endpoint, showing every pitfall from this lesson stacked together — then fixed, one at a time:
// BEFORE — five separate performance problems in one method
public SearchResponse SearchCatalog(List<Product> catalog, string category, decimal maxPrice)
{
var results = catalog
.OrderBy(p => p.Price) // (4) sorts everything before filtering
.Select(p => new ProductDto(p.Name, p.Price)) // projects everything before filtering
.Where(p => p.Price <= maxPrice); // filter runs LAST, too late to help
if (results.Any()) // (1) pass #1 over the whole chain
{
var list = results.ToList(); // pass #2 — re-runs everything again
int total = results.Count(); // (2) pass #3 — Count() can't shortcut here, source isn't a collection
return new SearchResponse(list, total);
}
return new SearchResponse([], 0);
}// AFTER — filter first, project after, materialize exactly once, reuse the result
public SearchResponse SearchCatalog(List<Product> catalog, string category, decimal maxPrice)
{
var results = catalog
.Where(p => p.Category == category && p.Price <= maxPrice) // filter FIRST — shrinks the set immediately
.OrderBy(p => p.Price)
.Select(p => new ProductDto(p.Name, p.Price))
.ToList(); // forced ONCE
return new SearchResponse(results, results.Count); // .Count property — O(1), no re-enumeration
}The "after" version enumerates the catalog exactly once, sorts and projects only the items that survived filtering, and reads the final count from the materialized list's .Count property instead of calling Count() against a still-deferred query. Every one of the five pitfalls above maps directly onto one specific fix in this rewrite.
Imagine moving apartments: a hundred boxes sit in the old place, and only twenty are actually going to the new one. Sorting all hundred boxes alphabetically before figuring out which twenty to keep wastes enormous effort on boxes about to be left behind. The efficient approach is obvious once stated: pick the twenty first, then sort just those. That's exactly "filter before you sort" — LINQ doesn't know which items you'll eventually keep until Where tells it, so give Where the earliest possible say in the chain.
Where, Select, or custom extension in a chain is a state machine object. Building a five-call chain allocates five small objects — genuinely negligible on modern .NET for ordinary collection sizes, but it's real, measurable cost, which is why "LINQ has zero overhead versus a loop" is an overstatement, even if the overhead is usually irrelevant in practice.if statement inside a hand-written loop. Again, small per call, but it multiplies by however many items pass through it.ToList() grows an internal array and copies every produced item into it — real, visible allocation. This is precisely why calling it more than once in a chain, or calling it when nothing downstream actually needed a materialized result, is worth avoiding.For the overwhelming majority of everyday code — reasonably sized in-memory collections, queries run once — the difference between a well-written LINQ chain and an equivalent hand-written loop is immaterial, often unmeasurable outside of a microbenchmark. The pitfalls in this lesson are about avoiding redundant work (the same data processed more times than necessary), not about LINQ being inherently, unavoidably slower than a loop doing the identical amount of work.
None of the five patterns in this lesson require profiling to justify — filtering before sorting, avoiding multiple enumeration, and not calling ToList() twice in a row are simply the correct way to write the query the first time, at no cost in readability. That's different from micro-optimizing a loop by hand before you have any evidence it's a bottleneck — this lesson is about writing clean LINQ well, not about abandoning LINQ preemptively.
Count() > 0 instead of Any() if (products.Where(p => p.Stock > 0).Count() > 0) forces a full count of every matching item, just to check whether at least one exists. if (products.Any(p => p.Stock > 0)) stops at the very first match — for a sequence with thousands of matches, this is the difference between checking one item and checking all of them.
Forcing a genuinely simple accumulation ("sum these, but also log every tenth one") through several chained LINQ calls with side effects smuggled into a Select, just because "LINQ is more idiomatic." When a loop needs multiple side effects per iteration, or the logic doesn't cleanly decompose into filter/project/sort, a plain foreach loop is often clearer and just as fast — this is exactly the judgment call the next section covers.
The most important skill in this lesson isn't memorizing five rules — it's the judgment to know when LINQ's readability is worth its (usually small) cost, and when it isn't:
ToList() before touching it a second time..Count/.Length is O(1) on a concrete collection — Count() loses that shortcut the moment a deferred operator comes before it.ToList() when a result is used more than once..Count/.Length are O(1) properties on concrete collections; Count() loses its own O(1) shortcut the moment a deferred operator precedes it in the chain.ToList() exactly once, at the end of a chain — never mid-chain, where it only adds a wasted allocation.Where early — filtering before sorting or projecting means every later operator handles less data.That closes out Part IV — LINQ. From Where and Select through deferred execution, both syntaxes, custom extensions, and now performance, you have the complete, practical toolkit for querying data in C# the way it's actually written in production code every day.
You've learned the key LINQ performance patterns that close out this module. Let's check your understanding.
1. A deferred query is stored in a variable, then enumerated three separate times later in the method — once with Any(), once with Count(), and once with foreach. What's the performance concern?
Correct: B
Why B is correct: As shown in How It Works (Pitfall 1) and Why Does It Exist?, a deferred query has no memory of previous enumerations — each one independently re-runs the entire chain, tripling the real work in this scenario.
Why A is incorrect: LINQ never caches deferred query results automatically — that's precisely why ToList() exists, to force and store a result deliberately.
Why C is incorrect: Re-enumerating a deferred query multiple times is completely valid, exception-free C# — it's just potentially wasteful, not an error.
Why D is incorrect: All three — Any(), Count(), and foreach — genuinely enumerate the query and run real work each time.
Reinforcement: Whenever a query result will be touched more than once, materialize it with ToList() first.
2. Why is products.Where(p => p.Stock > 0).Count() an O(n) operation, while products.Count() (with no Where) is O(1)?
Correct: B
Why B is correct: As explained in How It Works (Pitfall 2), Count()'s O(1) shortcut depends on its source still being a concrete ICollection<T> like List<T> — Where's output is a lazy iterator, not a collection, so that shortcut is unavailable and Count() must enumerate everything to produce an answer.
Why A is incorrect: products.Count() with no Where genuinely does get the O(1) shortcut, as shown directly in the code example — it isn't a coincidence, it's a documented behavior of Enumerable.Count().
Why C is incorrect: This combination compiles and runs correctly — it's simply less efficient than it might look, not erroneous.
Why D is incorrect: The whole point of this pitfall is that the cost genuinely differs based on what precedes Count() in the chain.
Reinforcement: The O(1) shortcut for Count() quietly disappears the moment any deferred operator sits between the source collection and the call.
3. Which reordering improves performance, and why: products.OrderBy(p => p.Price).Where(p => p.Category == "Electronics") vs. products.Where(p => p.Category == "Electronics").OrderBy(p => p.Price)?
Correct: B
Why B is correct: As shown in How It Works (Pitfall 4) and the Analogy, sorting the entire source before filtering wastes effort sorting items that are about to be discarded — filtering first means the sort only ever has to handle the smaller, already-narrowed set.
Why A is incorrect: The order genuinely changes how much work each operator does — this is the entire point of the pitfall.
Why C is incorrect: Where does not perform binary search regardless of prior sorting — it always checks the predicate against each item in sequence.
Why D is incorrect: OrderBy is deferred at the call site, but as the Sorting lesson established, it still must pull the entire source through and fully sort it the moment enumeration actually begins — it isn't skipped.
Reinforcement: Put Where as early in a chain as the query's logic allows, so every operator after it works with less data.
4. When, according to this lesson, should you reach for a hand-written loop instead of LINQ?
Correct: C
Why C is correct: As stated directly in When Should I Use It?, the practical rule is to write clean LINQ first and only switch to a hand-written loop once profiling actually justifies it, or when the logic's shape (multiple side effects, complex early-exit conditions) doesn't map cleanly onto LINQ's vocabulary.
Why A is incorrect: This lesson explicitly pushes back on "LINQ is always slower" as a myth — hand-written loops aren't automatically better.
Why B is incorrect: The lesson explicitly acknowledges cases — complex side effects, proven bottlenecks — where a loop genuinely serves better.
Why D is incorrect: No specific item-count threshold was given anywhere in this lesson — the decision is about proven bottlenecks and code shape, not an arbitrary size cutoff.
Reinforcement: "Write it well in LINQ first, optimize only with evidence" is the single most important habit this capstone lesson teaches.
5. What is the core difference between products.Where(p => p.Id == 42).First() and products.First(p => p.Id == 42)?
Correct: C
Why C is correct: As shown in Simple Example, both produce the identical matching product — the difference is purely one of avoiding an unneeded intermediate step, since First(predicate) can search and stop directly, without constructing a separate Where layer first.
Why A is incorrect: Both expressions locate the exact same item under the exact same condition.
Why B is incorrect: Both throw InvalidOperationException when nothing matches — First (as opposed to FirstOrDefault) never returns null in either form.
Why D is incorrect: There's no such requirement — First(predicate) accepts any boolean-returning lambda, && included, exactly like Where does.
Reinforcement: Prefer the predicate-overload versions (First, Any, Single) directly over combining Where with a parameterless call — a small, easy habit with a real, if modest, payoff.
Congratulations — you've completed Part IV: LINQ. You can now filter, project, sort, group, join, and aggregate data fluently, understand exactly when your queries actually run, write them in either syntax, extend LINQ with your own operators, and write all of it with real performance awareness.
dotnetmadeeasy.com — Learn C# and .NET, the right way.