← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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.

Why Does It Exist?

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.

Big Picture

THE FIVE PATTERNS THIS LESSON COVERS
Multiple EnumerationSame deferred query, run more than once
Count() vs .CountA method call vs. a stored property
Unnecessary ToList()Materializing when nothing needed it
Operator OrderFiltering before sorting/projecting
Plus the judgment call that ties them together: when a hand-written loop genuinely beats LINQ.

How It Works

Pitfall 1 — Multiple Enumeration

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 list

Three 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.

Pitfall 2 — Count() the Method vs .Count/.Length the Property

Every 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 gone

The 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().

Pitfall 3 — Unnecessary ToList() Calls Mid-Chain

Already 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();

Pitfall 4 — Operator Order: Filter Before You Sort or Project

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.

Simple Example

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.

Real-World Example

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.

Analogy

Sort the Boxes You're Keeping, Not All of Them

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.

Under the Hood

WHERE THE COST ACTUALLY COMES FROM
1. EVERY yield return OPERATOR ALLOCATES A SMALL ITERATOR OBJECT
2. DELEGATE INVOCATION HAS A SMALL, FIXED COST PER CALL
3. MATERIALIZING OPERATORS (ToList, ToArray) ALLOCATE AND COPY

Common Confusion

1. "LINQ is always slower than a loop" — a real myth, not a rule

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.

2. Premature optimization vs. these specific patterns

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.

Common Mistakes

Mistake 1 — Using 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.

Mistake 2 — Rewriting a simple, single-pass loop as an over-engineered LINQ chain

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.

When Should I Use It?

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:

LINQ is the right default when

A hand-written loop may serve better when

The practical rule: Write it in LINQ first — clean, one operator per line, filter before you sort or project, materialize exactly once at the end. Only reach for a hand-written loop after profiling shows this specific piece of code genuinely needs it. Across nearly all real-world business code, "write it well in LINQ" and "write it fast" turn out to be the very same advice.

Mental Model

LINQ performance = "do the same amount of real work a loop would — never more, never redundantly"

Remember:
· Enumerate a deferred query once; materialize with 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.
· Filter early, sort and project late — shrink the sequence before doing the expensive work on it.
· Materialize exactly once, at the very end of a chain, never mid-chain.
· Write clean LINQ first; reach for a hand-written loop only after profiling actually justifies it.

Key Takeaway

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.


Check Your Understanding

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?

Show answer

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)?

Show answer

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)?

Show answer

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?

Show answer

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)?

Show answer

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.