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

Intermediate lesson 122 taught you the patterns to avoid. This lesson explains, at the CLR level, exactly what those patterns cost — and one boxing trap most working developers have never seen named.

Intermediate lesson 122 gave you five practical, correct patterns: avoid multiple enumeration, prefer .Count over Count(), materialize once, filter before sorting, don't over-engineer simple loops into LINQ. Every one of those held up under scrutiny and remains correct advice. What that lesson didn't do — deliberately, since you didn't yet have lessons 196 through 199 — is show you exactly what a chained LINQ operator allocates, or the single most subtle, well-documented performance detail in all of .NET collection iteration: the moment a concrete collection's struct enumerator gets boxed, and why that moment arrives earlier than most developers ever notice.

You'll trace, precisely, what each operator in a LINQ chain allocates on the heap; the exact mechanism by which foreach avoids allocation and virtual dispatch entirely for a concrete, struct-enumerator collection like List<T> — and exactly why that advantage silently disappears the moment the same collection is accessed through an IEnumerable<T>-typed variable, which is precisely what happens the instant you pass it into any LINQ method. And you'll meet BenchmarkDotNet, the correct tool for actually measuring any of this, rather than guessing.

What Is It?

The Simple Explanation

Every LINQ operator you chain and every foreach loop you write ultimately becomes ordinary CLR-level work: objects allocated on the heap, virtual method calls dispatched through an interface, or — when the compiler can prove a concrete type — a plain, non-virtual call with no allocation at all. Intermediate 122 taught you the query-shaped patterns; this lesson goes one level lower, to the allocation and dispatch decisions the compiler and runtime actually make underneath every one of those patterns.

The Technical Definition

Two independent, real .NET facts anchor this lesson: (1) each LINQ operator in a chain is a separate iterator object — as lesson 196 established, calling Where allocates a state-machine instance, and chaining Select onto it allocates a second, wrapping the first — so a five-operator chain performs five separate small heap allocations before a single item is even produced; and (2) List<T>, arrays, and several other BCL collection types expose a struct-typed enumerator through their own concretely-typed GetEnumerator() method specifically so that a foreach loop, when it can see that concrete type at compile time, can call MoveNext()/Current directly on that struct — with zero heap allocation and zero virtual dispatch. That optimization depends entirely on the compiler being able to see the concrete type; lose that visibility, and the optimization is gone.

Why Does It Exist?

The Problem — Iteration Is the Single Hottest Path in Most .NET Programs

Looping over a collection is one of the single most frequently executed pieces of code in almost any real application — it happens constantly, often over collections with thousands or millions of elements, often inside code that runs many times per second. Any per-iteration overhead — an extra heap allocation, an extra virtual call that the CPU's branch predictor can't easily predict — gets multiplied by however many times the loop runs. For a loop that runs ten times, this is irrelevant. For a loop that runs inside a hot path processing millions of items, it's measurable, and .NET's designers built foreach's compiler behavior specifically to make the common case — a concrete collection type, known at compile time — as cheap as possible.

The Solution — Pattern-Based foreach, Plus Struct Enumerators, When the Compiler Can See Them

foreach in C# is not strictly bound to the IEnumerable<T> interface at all — this is a genuinely underappreciated fact. The compiler uses duck typing: if the compile-time type of the collection expression has its own public GetEnumerator() method (whether or not that type implements IEnumerable<T>) returning something with a Current property and a MoveNext() method, the compiler binds directly to that specific method, rather than going through the interface. List<T> exposes exactly such a method, returning its own public struct List<T>.Enumerator — a value type. When foreach binds to it directly, the entire loop operates on a struct sitting on the stack (or inlined into the enclosing frame), calling its MoveNext() as an ordinary non-virtual method call. No allocation. No interface. No virtual dispatch.

Big Picture

THE SAME foreach LOOP, TWO WAYS TO DECLARE THE VARIABLE
List<int> nums = [1,2,3];
Compile-time type: List<int> — CONCRETE
foreach binds directly to List<int>.GetEnumerator()
Returns: struct List<int>.Enumerator
Zero allocation. Non-virtual calls.
IEnumerable<int> nums = [1,2,3];
Compile-time type: IEnumerable<int> — INTERFACE
foreach can only call the INTERFACE's GetEnumerator()
Returns: IEnumerator<int> — the struct gets BOXED to satisfy it
One heap allocation. Virtual dispatch on every MoveNext()/Current.
Same runtime object, same three elements — one extra type annotation is the entire difference.

How It Works

Fact 1 — Every Operator in a LINQ Chain Is Its Own Allocation

Building directly on lesson 196: calling Where allocates one state-machine instance. Chaining Select onto the result allocates a second state-machine instance, whose captured source field is the first one. Chain five operators, and you've allocated five separate small objects, nested inside each other, before a single item has been pulled through any of them:

var query = products .Where(p => p.Stock > 0) // allocation #1 — a WhereEnumerable... state machine .Select(p => p.Name) // allocation #2 — wraps #1 .Where(name => name.Length > 3) // allocation #3 — wraps #2 .OrderBy(name => name) // allocation #4 — a different shape (buffers, see below) .Take(10); // allocation #5 — wraps #4 // FIVE small objects allocated the instant this statement runs — BEFORE // the query variable is even enumerated once. None of them are large, // none of them individually matter, but they are real, measurable allocations.

Individually, these are small — typically a few dozen bytes each, cheap for the .NET garbage collector's generation-0 collector to reclaim (a concept covered in Advanced Part I's Generational GC lesson). The point isn't that this is slow in any absolute sense — for ordinary collection sizes it's genuinely negligible. The point, echoing Intermediate 122's own honest framing, is that "LINQ composition is free" is measurably false; it's just usually cheap enough not to matter.

Fact 2 — Struct Enumerators and the Boxing Trap

List<T>'s actual declaration (simplified) makes the struct enumerator visible:

public class List<T> : IList<T>, IReadOnlyList<T>, ... { // The CONCRETELY-TYPED GetEnumerator() — returns a STRUCT, not an interface public Enumerator GetEnumerator() => new Enumerator(this); // ALSO implements IEnumerable<T>.GetEnumerator() explicitly — returns the SAME // struct, but BOXED into an IEnumerator<T> reference, to satisfy the interface IEnumerator<T> IEnumerable<T>.GetEnumerator() => new Enumerator(this); public struct Enumerator : IEnumerator<T> { // fields tracking position — same shape of idea as lesson 196's compiler-generated fields, // except THIS one is hand-written by the BCL team, as a value type, deliberately public T Current { get; private set; } public bool MoveNext() { ... } // ... } }

Two separate GetEnumerator() methods exist on purpose: the public, concretely-typed one (returning the struct directly, callable when the compiler knows the type is List<T>) and an explicit interface implementation of IEnumerable<T>.GetEnumerator() (only reachable when the compiler only knows the type as IEnumerable<T>, and forced to box the struct into a heap-allocated IEnumerator<T> reference to satisfy the interface's return type). foreach's compile-time duck-typing lookup, described in "Why Does It Exist?" above, is precisely the mechanism that decides which of these two gets called — and it decides based purely on the compile-time type of the collection expression.

Simple Example

Four ways to loop over the exact same three-element list, differing only in the declared type — the boxing trap made concrete:

List<int> numbers = [1, 2, 3, 4, 5]; // ── #1 — Declared as List<int> — the FAST path ── foreach (int n in numbers) { // Compiler sees "numbers" is List<int> (concrete) at compile time. // Binds DIRECTLY to List<int>.GetEnumerator() → returns the STRUCT. // NO boxing. NO virtual call. MoveNext()/Current are plain, inlinable calls. } // ── #2 — Declared as IEnumerable<int> — the SLOW path, EVEN THOUGH IT'S THE SAME LIST ── IEnumerable<int> numbersAsInterface = numbers; foreach (int n in numbersAsInterface) { // Compiler only sees "numbersAsInterface" as IEnumerable<int> — an INTERFACE. // Can ONLY call the interface's GetEnumerator() → the struct gets BOXED // into a heap-allocated IEnumerator<int> reference. // MoveNext()/Current are now VIRTUAL calls through that interface reference. } // ── #3 — Passed into ANY LINQ method — the SAME slow path, silently ── var evens = numbers.Where(n => n % 2 == 0); // Where<T>'s signature is: Where<T>(this IEnumerable<T> source, ...) // The MOMENT "numbers" is passed here, it's accessed through an // IEnumerable<int>-typed PARAMETER inside Where's own implementation — // so Where's internal foreach over "source" ALSO takes the boxed, virtual path, // exactly like example #2, even though YOU wrote "numbers.Where(...)" and // "numbers" itself is still declared as List<int> at YOUR call site. // ── #4 — A method parameter typed IEnumerable<T> — the same trap, one level removed ── void PrintAll(IEnumerable<int> source) { foreach (int n in source) // source's compile-time type here is IEnumerable<int> — boxed path Console.WriteLine(n); } PrintAll(numbers); // even though the CALLER's "numbers" is a concrete List<int>

Code → Meaning → Result: All four examples iterate the exact same three-element List<int> instance, producing identical output. Only #1 avoids boxing entirely. The critical, easy-to-miss realization is #3: writing numbers.Where(...) feels like it should be just as fast as a direct foreach over numbers, since numbers itself never changed its declared type at the call site — but Where's own internal implementation only ever sees its source parameter as IEnumerable<T>, because that's the parameter type LINQ declares. The boxing happens inside Where, invisibly to you, every single time.

Real-World Example

A hot-path method in a game engine or a real-time data pipeline — processing a per-frame or per-tick list of thousands of entities, many times per second — is exactly the scenario where this distinction stops being academic:

public class Simulation { private readonly List<Entity> _entities = []; // Typed IEnumerable<Entity> — every single call to this method boxes the // enumerator and pays virtual dispatch, EVERY frame, for EVERY entity public void UpdateAll_Slow(IEnumerable<Entity> entities) { foreach (var entity in entities) entity.Update(); } // Typed List<Entity> — the concrete struct enumerator, zero allocation, // non-virtual MoveNext()/Current, every single frame public void UpdateAll_Fast(List<Entity> entities) { foreach (var entity in entities) entity.Update(); } public void Tick() { UpdateAll_Fast(_entities); // called 60+ times per second, thousands of entities each time } }

For a one-off report generator running once per user click, this difference genuinely doesn't matter — the cost is a handful of small, cheap gen-0 allocations, over in microseconds. For Tick(), called sixty times a second, over thousands of entities, every single frame of the entire session — the accumulated allocation pressure from the boxed path is a real, measurable difference in garbage collector workload, which is precisely why real-time and high-throughput .NET code (game engines, high-frequency trading systems, hot networking paths) is written with this specific distinction in mind, deliberately preferring concrete collection types over interface types on the hottest of hot paths.

Analogy

A Tool in Your Hand vs a Tool Behind a Locked Cabinet

A struct enumerator accessed through its concrete type is like a tool already in your hand — you use it directly, immediately, with no extra step. The same tool accessed only through an IEnumerable<T> reference is like that same tool locked inside a labeled cabinet: before you can use it, someone has to take a photograph of it (box it — copy its state onto the heap), hand you the photograph, and every time you want to "use the tool" you're actually describing what you want done to someone else standing at the cabinet, who does it on your behalf (a virtual call). The tool itself never changed. What changed is how indirectly you're forced to reach it — and that indirection is exactly what boxing plus virtual dispatch costs.

Under the Hood

TYING IT BACK TO EARLIER ADVANCED-MODULE LESSONS
1. BOXING ITSELF IS THE EXACT MECHANISM FROM ADVANCED PART I, LESSON 176
2. WHY THE JIT CAN'T "JUST OPTIMIZE THIS AWAY"
3. ARRAYS GET SPECIAL COMPILER TREATMENT TOO
4. MEASURE IT — DON'T GUESS: BenchmarkDotNet

Common Confusion

1. "Since numbers is still declared List<int> at my call site, numbers.Where(...) must be using the fast path" — no, the boxing happens inside Where, not at your call site

This is the single most common misreading of this lesson's central point, worth restating plainly: your variable's declared type at the point you write numbers.Where(...) has no bearing on how Where's own internal implementation accesses that data once it's inside the method. Where<T> is declared to take this IEnumerable<T> source — the instant your List<int> is passed as that parameter, every access to it inside Where's body happens through an IEnumerable<T>-typed reference, regardless of what type it was outside.

2. "This means I should avoid LINQ and type everything as concrete collections everywhere" — that's an overcorrection Intermediate 122 already warned against

Nothing in this lesson contradicts Intermediate 122's central, still-correct message: for the overwhelming majority of real-world code, this cost is genuinely negligible, and LINQ's readability is worth it. This lesson exists to make the mechanism precise for the specific cases where it does matter — a proven hot path, a very large collection processed extremely frequently — not to argue that every foreach loop in every application should be hand-optimized around struct enumerators as a default habit.

Common Mistakes

Mistake 1 — Typing a genuinely hot-path method parameter as IEnumerable<T> out of habit

Reflexively typing every collection parameter as the "most abstract" interface, even on a method known (through actual profiling — see Mistake 3) to be called millions of times per second in a tight loop: void UpdateAll(IEnumerable<Entity> entities). On a genuinely proven hot path, prefer the concrete type your callers actually have (List<Entity>, or a generic constraint like where TCollection : IEnumerable<T> that still preserves struct-enumerator devirtualization for the caller's concrete type), as shown in the Real-World Example above.

Mistake 2 — Assuming every collection type even has a struct enumerator to lose

Applying this lesson's boxing concern to every collection type uniformly — Dictionary<TKey,TValue>, HashSet<T>, and arrays all also expose their own struct enumerators (with the same interface-typing caveat), but plenty of custom or third-party enumerable types genuinely only implement the plain interface, with no concrete struct enumerator to optimize toward in the first place. This distinction matters specifically for BCL types documented to expose a struct enumerator — verify (or benchmark) rather than assuming it applies universally to every enumerable type you encounter.

Mistake 3 — Hand-optimizing around this before profiling has identified an actual bottleneck

Rewriting ordinary, non-hot-path application code to avoid IEnumerable<T> parameters "just in case," at a real cost to API flexibility and readability, with no evidence the code in question is ever a meaningful contributor to overall performance. Exactly as Intermediate 122 concluded for its own five patterns: write clean, readable code first — reach for BenchmarkDotNet to confirm a genuine hot path exists, and only then, with real numbers in hand, consider trading some abstraction for the concrete-type optimization this lesson describes.

When Should I Use It?

Reach for concrete collection types (and skip LINQ) when

Keep writing ordinary LINQ and IEnumerable<T> parameters when

Rule of thumb: Know this mechanism exists so you can recognize it when profiling points at it. Don't reach for it as a default habit — the same "write it clean first, optimize only once profiling justifies it" advice from Intermediate 122 applies just as strongly here, now with a concrete, measurable mechanism (boxing, virtual dispatch) behind it instead of a vague sense that "interfaces might be slower."

Mental Model

Each chained LINQ operator = "one more small heap allocation, wrapping the one before it"
foreach over a concretely-typed List<T>/array = "the tool already in your hand — no boxing, no virtual call"
foreach (or any LINQ call) over an IEnumerable<T>-typed reference = "the same tool, now behind a locked cabinet — boxed, virtual"

Remember:
· A five-operator LINQ chain allocates five small objects before the first item is produced.
· List<T>, arrays, and some other BCL types expose a struct enumerator — free of allocation and virtual dispatch, but ONLY when the compiler sees the concrete type.
· Passing a concrete collection into ANY IEnumerable<T>-parameter method (including every LINQ operator) boxes its struct enumerator, invisibly, every single call.
· Measure with BenchmarkDotNet before trading readability for this optimization — it matters on proven hot paths, and is usually irrelevant everywhere else.

Key Takeaway

That closes Part III — LINQ Deep Dive. You now understand the compiler-generated iterator machinery underneath every LINQ-to-Objects operator, the expression-tree mechanism underneath every LINQ-to-Queryables/EF Core query, how a provider actually reads that tree, the real distinction between the two worlds' deferred execution, and the concrete allocation and dispatch costs both worlds carry. The next module goes further still — into custom LINQ providers, LINQ-to-EF-Core translation in depth, and avoiding LINQ's real-world performance traps in production systems.


Check Your Understanding

You've traced LINQ's real allocation cost and one of .NET's most subtle, well-documented performance details. Let's check your understanding.

1. Why does chaining five LINQ operators (Where().Select().Where().OrderBy().Take()) result in five separate heap allocations before any item is enumerated?

Show answer

Correct: B

Why B is correct: As Fact 1 traced directly, each operator call constructs its own wrapping iterator object the instant it's called — five operators means five separate objects, each holding a reference to the one before it, all before MoveNext() is ever called on any of them.

Why A is incorrect: No result set is pre-allocated — LINQ to Objects operators are lazy and streaming; nothing about the final result size is known or reserved in advance.

Why C is incorrect: This isn't a general C# rule about method chains — it's specific to how LINQ's operators happen to be implemented, as iterator-wrapping-iterator objects.

Why D is incorrect: This is exactly the "LINQ composition is free" myth this lesson corrects — each operator genuinely remains its own separate object at runtime, not fused into one loop.

Reinforcement: Every LINQ operator call is a real allocation, made at chain-construction time, independent of when (or whether) enumeration ever happens.

2. A List<int> variable is looped over with foreach directly. Why does this avoid both heap allocation and virtual dispatch?

Show answer

Correct: B

Why B is correct: As "Why Does It Exist?" and How It Works both explained, foreach uses compile-time duck typing to bind directly to a concrete type's own GetEnumerator() when one is visible — List<T>'s returns a struct, avoiding both the boxing and the virtual dispatch that going through the interface would require.

Why A is incorrect: foreach genuinely does use enumerators for List<T> — arrays get special direct-indexing treatment instead, but List<T> specifically uses its struct enumerator.

Why C is incorrect: List<T> genuinely does implement IEnumerable<T> — it just also exposes a separate, concretely-typed GetEnumerator() that foreach prefers when it can see the concrete type.

Why D is incorrect: There's no such caching mechanism — a fresh struct enumerator (on the stack, not cached) is produced for each foreach loop.

Reinforcement: Compile-time visibility of the concrete type is the entire trigger for this optimization — nothing about the collection's runtime identity matters.

3. List<int> numbers = [1,2,3]; numbers.Where(n => n > 1); — does the struct-enumerator optimization apply here, even though numbers is still declared as List<int> at this call site?

Show answer

Correct: B

Why B is correct: This is exactly Common Confusion point 1 — the boxing happens inside Where's own implementation, driven by Where's own parameter type, entirely independent of how numbers happened to be declared at your call site.

Why A is incorrect: This is precisely the misconception this lesson corrects — your variable's declared type outside the call has no bearing on how the callee accesses it internally.

Why C is incorrect: Query syntax and method syntax compile to the identical underlying method calls — there's no difference in boxing behavior between the two.

Why D is incorrect: Where's real implementation (lesson 196) does use a foreach internally over its source parameter — and that foreach is exactly where the boxing in question happens, since source is typed IEnumerable<T> there.

Reinforcement: Boxing is determined by the compile-time type at the point of actual access — inside the callee, not at your own call site.

4. According to this lesson, what is the correct way to determine whether the boxing/allocation costs described here are actually worth optimizing away in a specific piece of code?

Show answer

Correct: B

Why B is correct: As Under the Hood point 4 and "When Should I Use It?" both concluded, this lesson explicitly recommends measuring with BenchmarkDotNet and reserving this optimization for proven hot paths — never applying it as a blanket, unmeasured default.

Why A is incorrect: This is explicitly called out as Mistake 3 and Common Confusion point 2 — an unmeasured, blanket rewrite trades away readability and API flexibility for a cost that's usually irrelevant.

Why C is incorrect: The Real-World Example showed a genuine, real scenario (a per-frame hot loop) where this cost is measurably significant — it's real, just not universally significant.

Why D is incorrect: No such fixed threshold exists in .NET or in this lesson's guidance — the right trigger is measured evidence of a bottleneck, not an arbitrary operator count.

Reinforcement: Know the mechanism, but let actual measurement — not intuition or a fixed rule — decide when it's worth acting on.

You've completed Part III — LINQ Deep Dive. Iterators, expression trees, query providers, deferred execution across both worlds, and the real allocation/dispatch costs underneath all of it — you now understand LINQ's mechanics from the inside out. Part IV continues directly from here: custom LINQ operators revisited, LINQ to Objects and LINQ to EF Core compared in depth, and avoiding LINQ's real-world performance traps in production.


dotnetmadeeasy.com — Learn C# and .NET, the right way.