Every yield return method you've ever written became an entire hidden class. This lesson opens that class up and reads it line by line.
You've used IEnumerable<T> since Foundations lesson 038. You've written yield return dozens of times since — in custom collections, in custom LINQ extensions, in the Where and Select reimplementations from Intermediate Part IV. Every one of those lessons told you the same thing in passing: "the compiler turns this into a state machine." None of them showed you that state machine's actual shape. This lesson does exactly that — no more taking it on faith.
You'll see the literal interface members behind foreach, the exact statement-by-statement translation the compiler performs on a foreach loop, and — the centerpiece — a full, field-by-field walkthrough of the hidden class the compiler generates for a yield return method: its numeric state field, its captured-variable fields, and exactly how MoveNext() resumes execution from precisely where the last call left off. By the end, "Where is lazy because it's built with yield return" stops being a fact you were told and becomes a mechanism you can trace yourself.
IEnumerable<T> and IEnumerator<T> are two small, separate contracts that together define "something that can be walked through, one item at a time." You've used both since Foundations. What this lesson adds is the machinery underneath: what the compiler actually builds when you write yield return instead of hand-writing an enumerator class yourself, and exactly why that machinery is what makes an entire LINQ operator like Where do nothing at all until something starts pulling items through it.
Both interfaces, in full, exactly as the BCL declares them:
public interface IEnumerable<out T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}
public interface IEnumerator<out T> : IEnumerator, IDisposable
{
T Current { get; }
// Inherited from the non-generic IEnumerator:
// bool MoveNext();
// void Reset();
// object Current { get; } (the non-generic version)
}IEnumerator<T> additionally implements IDisposable — this is why the compiler-generated foreach translation you'll see in a moment always wraps the loop in something that guarantees Dispose() runs, even when the loop exits early via break, return, or an exception. An iterator that holds an open file handle, a database connection, or any other unmanaged resource relies on that guarantee to clean up correctly no matter how the loop ends.
Reset() Is Effectively Dead CodeReset() exists for historical reasons — it predates generics, from the days of COM-style enumerators used by early .NET collection classes. In practice, almost no modern IEnumerator<T> implementation does anything meaningful with it. Every compiler-generated yield return state machine — which is the overwhelming majority of enumerators you'll ever touch in modern C# — implements Reset() by simply throwing NotSupportedException. If you need to "start over," the real, idiomatic answer is to call GetEnumerator() again and get a brand-new enumerator — never to call Reset() on an existing one.
Foundations lesson 038 showed you what a hand-written IEnumerator<T> looks like: a private class, a position field, a MoveNext() that increments it and bounds-checks, a Current that indexes into the source. For a flat array, that's manageable. For logic with branches, loops, or multiple exit conditions — imagine hand-writing the enumerator for something like "every third even number, skipping the first five, until a total exceeds 100" — the position bookkeeping alone becomes its own small, fragile state machine, entirely separate from the logic it's supposed to express.
yield return lets you write the iteration logic as ordinary-looking, straight-line code with loops and conditionals — the ones from the paragraph above — and the C# compiler mechanically rewrites the entire method body into a class that implements IEnumerable<T> and IEnumerator<T>, correctly, every time. You never see that class in your source code. It exists only in the compiled assembly. This lesson's whole purpose is showing you what it actually contains.
IEnumerable<int> EvensBelow(int max)
{
for (int i = 0; i < max; i++)
if (i % 2 == 0)
yield return i;
}
foreach (int n in EvensBelow(10))
Console.WriteLine(n);
EvensBelow's body becomes a hidden class implementing IEnumerable<int> / IEnumerator<int> — a state machine.
foreach becomes a GetEnumerator() call plus a try/finally-wrapped while (MoveNext()) loop.
IEnumerable<T>/IEnumerator<T> contract — which is exactly why any hand-written enumerator (Foundations 038) plugs into foreach just as seamlessly as a compiler-generated one.
foreach DesugarsThis is Translation B from above, shown in full. It's the same shape you saw a first look at in Foundations 038, repeated here precisely because everything else in this lesson depends on it:
// You write:
foreach (var item in source)
{
DoSomething(item);
}
// The compiler generates (roughly):
{
IEnumerator<T> e = source.GetEnumerator();
try
{
while (e.MoveNext())
{
T item = e.Current;
DoSomething(item);
}
}
finally
{
e.Dispose(); // ALWAYS runs — even on break, return, or an exception inside the loop
}
}The try/finally is the detail worth pausing on. It means Dispose() is called on the enumerator no matter how the loop body exits — a break midway through, a return from inside the loop, or an exception thrown by DoSomething. This matters more than it looks: as you'll see below, a yield return state machine's Dispose() is what runs any finally blocks that exist inside the iterator method itself — so a resource opened inside an iterator (a file, a connection) is guaranteed to close correctly even if the caller abandons the loop halfway through.
yield return Compiles IntoNow Translation A. Take a small iterator method with one captured parameter and one local variable used across iterations:
public IEnumerable<int> CountUpBy(int start, int step)
{
int current = start;
while (current < 20)
{
yield return current;
current += step;
}
}The compiler does not compile this as an ordinary method at all. The moment it sees yield return in a method body, it switches to generating an entire hidden class — conventionally named something like <CountUpBy>d__0 — that implements both IEnumerable<int> and IEnumerator<int> on the same type. Four pieces make it work:
int field (typically named something like <>1__state) records exactly which point in the method the last MoveNext() call paused at — "before the loop," "just yielded inside the loop," "finished." Every MoveNext() call starts by checking this field and jumping straight to the matching point via a switch, rather than starting the method over from line one.start, step, and current all become ordinary instance fields on the hidden class — because a local variable on the call stack disappears the instant a method returns, but this method needs to "return" (pause) after every single yield return and still remember current's exact value for next time. Only fields on a heap-allocated object can survive that.Current property) holds whatever value the most recent yield return produced, so Current can simply return it without recomputing anything.yield return statements all move — largely intact — into MoveNext(), with a goto-based jump table at the top (driven by the state field) that resumes execution at the exact statement after wherever the previous call left off, and a return true injected at every point that used to be a yield return.Here is the hidden class the compiler generates for CountUpBy above, written out by hand — simplified for readability, but faithful to the real shape and the real fields you'd see decompiling the actual IL:
// Conceptually equivalent to what the compiler generates for CountUpBy(start, step):
private sealed class <CountUpBy>d__0 : IEnumerable<int>, IEnumerator<int>
{
private int <>1__state; // WHERE execution paused
private int <>2__current; // the value the last yield return produced
public int start; // captured parameter → field
public int step; // captured parameter → field
private int <current>5__2; // the LOCAL "current" from your method → field
public int Current => <>2__current;
public bool MoveNext()
{
switch (<>1__state)
{
case 0: goto StateZero; // fresh enumerator, never started
case 1: goto StateOne; // resuming after a previous yield return
default: return false; // already finished
}
StateZero:
<>1__state = -1;
<current>5__2 = start; // int current = start;
WhileCheck:
if (!(<current>5__2 < 20))
goto Done;
<>2__current = <current>5__2; // about to yield return current;
<>1__state = 1; // remember: "paused right after a yield"
return true; // ← THIS is what yield return actually compiles to
StateOne:
<>1__state = -1;
<current>5__2 += step; // current += step; (resumes HERE next MoveNext() call)
goto WhileCheck;
Done:
return false;
}
public void Dispose() { /* runs any pending finally blocks from your method */ }
public void Reset() => throw new NotSupportedException();
// IEnumerable<int>.GetEnumerator() — usually just returns "this" the first time it's called
public IEnumerator<int> GetEnumerator() => this;
}
// And your original method becomes essentially:
public IEnumerable<int> CountUpBy(int start, int step)
{
var machine = new <CountUpBy>d__0();
machine.start = start;
machine.step = step;
return machine;
}Code → Meaning → Result: Calling CountUpBy(2, 3) does not run any of your loop logic — it only constructs the state-machine object and stashes the two parameters into fields. The first real work happens on the first MoveNext() call, which runs from the top of the method down to the first yield return, records "state 1" and the yielded value, and returns true. The next MoveNext() call doesn't restart the method — it jumps straight past the while check to the line right after the yield return (current += step;), because the state field told it exactly where to resume. This is the entire trick: pausing and resuming a method isn't magic — it's a saved integer, a switch, and fields standing in for what would otherwise be stack-local variables.
You've already met the real-world payoff of this mechanism, in Intermediate lesson 111 (Filtering), without seeing the full machinery behind it. Enumerable.Where is, at its core, exactly this pattern:
// Conceptually how System.Linq.Enumerable.Where is implemented:
public static IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(predicate);
return WhereIterator(source, predicate); // validation is eager; iteration is not — see lesson 121
}
private static IEnumerable<T> WhereIterator<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
foreach (T item in source)
{
if (predicate(item))
yield return item;
}
}Now you can trace, field by field, exactly why products.Where(p => p.Stock > 0) does nothing the instant it's called. That call constructs a state-machine object — call it <WhereIterator>d__1 — with fields for the captured source and predicate, and a state field sitting at its initial value. No item of products has been touched. Your p => p.Stock > 0 delegate has not been invoked even once. Only when something calls MoveNext() — a foreach, a ToList(), an Any() — does the generated code actually start pulling items from source and testing them against predicate, one at a time, pausing with a return true after every match. This is, precisely and completely, why deferred execution (the subject of lesson 199 next) is a fact about LINQ rather than a design choice someone bolted on separately — it falls straight out of what yield return compiles to.
Think of an ordinary bookmark: it remembers a page number, nothing more. A yield return state machine is a bookmark that also remembers every variable that was "in your head" while reading — which loop you were in, what you'd counted so far, what you were about to do next. Close the book (return from MoveNext()), and when you open it again (call MoveNext() a second time), you don't just find the right page — you pick up mid-thought, exactly where you left off, with every value you were tracking still intact. That's what the state field plus the captured-variable fields together provide: not just "where," but the entire mental state needed to resume correctly.
IEnumerable<T> and IEnumerator<T>. To correctly support two independent foreach loops running over the same call's result at once, its GetEnumerator() checks whether it's being called from the thread that originally created it and hasn't handed itself out yet — if so, it returns this directly (a small, real optimization avoiding an extra allocation); otherwise it constructs a fresh copy of the whole state machine, with the captured fields copied over, so the second walk-through gets its own independent state field and current-value field, exactly the same guarantee Foundations 038 described for hand-written enumerators.yield return method body has a try/finally around some of its logic, the compiler doesn't just leave that as ordinary code inside MoveNext() — it also wires the finally block into the generated Dispose() method, so that if a foreach consuming your iterator exits early (break, an exception, a LINQ operator like First() that stops early), the compiler-generated foreach translation's own finally { e.Dispose(); } from Part 1 guarantees your cleanup code still runs, even though MoveNext() never reached the natural end of your loop.MoveNext(), any code you write at the "top" of an iterator method — including argument validation — is just as deferred as everything else. This is exactly the bug lesson 121 (Custom LINQ Extensions) built an entire "Under the Hood" section around: an eager outer method plus a private yield return inner method is the standard fix, and now you know precisely why it's necessary — there is no "top of the method" that runs early; there is only MoveNext(), called on demand.class — a heap allocation. Some hand-written BCL types (notably List<T>'s own GetEnumerator()) instead return a struct that implements IEnumerator<T>, avoiding that allocation entirely when the compiler can see the concrete type at compile time. This is a separate, deliberate hand-written optimization — not something yield return itself produces — and it's the entire subject of lesson 200 (LINQ Performance) later in this module.This is the single most common misreading of how iterators work. MoveNext() does not restart your method from the top each time — the switch on the state field, shown explicitly in the Simple Example above, jumps directly to the exact statement after the last yield return. Code before that point in the method genuinely does not run again. This is precisely why an iterator can have expensive setup logic once, at the top, that only executes on the very first MoveNext() call — a real, commonly used pattern.
Both mechanisms solve the same underlying problem — a local variable needs to outlive the method call where it was declared — and both solve it by promoting the variable to a field on a heap-allocated object. But they're genuinely separate compiler features with separate generated types: lambda closures produce a "display class" (lesson 189) wrapping captured variables for a delegate; iterator methods produce this entirely different state-machine class, with its own state field and its own fields, purely for yield return. It's easy to conflate them because the underlying trick — "put the variable on the heap instead of the stack" — is philosophically identical, but seeing one doesn't mean you're looking at the other.
Passing a mutable object into an iterator method and assuming its state "at creation time" is what the iterator will see:
IEnumerable<int> ReadValues(List<int> source)
{
foreach (int v in source) // "source" is a FIELD on the state machine — read live, every MoveNext()
yield return v;
}
var list = new List<int> { 1, 2, 3 };
var iterator = ReadValues(list); // nothing has run yet — the field just holds a reference to 'list'
list.Add(4); // mutated BEFORE enumeration starts
foreach (var v in iterator) Console.WriteLine(v); // prints 1, 2, 3, 4 — sees the mutation The captured field holds a reference, read fresh on every access inside MoveNext() — just like any other field. If a stable snapshot at creation time is what's needed, copy the data explicitly (e.g. source.ToList()) before passing it in.
Reset() expecting it to rewind a compiler-generated enumerator enumerator.Reset(); on a state machine produced from a yield return method — this throws NotSupportedException at runtime, as covered in "What Is It?" above. Call GetEnumerator() again to get a fresh, independent enumerator instead — that's the only supported way to "start over" with modern iterators.
MoveNext()Assuming calling an iterator method "does nothing at all" — it does construct an object and copy every captured parameter/local into a field, which is a real (if small) heap allocation, before any of your logic runs. In a hot loop that calls an iterator method millions of times but only ever needs the first item, this per-call allocation is a real, measurable cost — one of the specific tradeoffs revisited in lesson 200's performance discussion. Recognize that "deferred" means "the body hasn't run yet," not "nothing has happened yet" — the object itself already exists.
You've been using yield return since Intermediate Part IV — this lesson isn't teaching you a new tool, it's giving you the mental model to reason correctly about the tool you already reach for.
Where/Select are lazy — the answer is now mechanical, not just quoted.<GetEnumerator>d__3.MoveNext() — you now know exactly what that frame is.yield return remains the correct, idiomatic way to write an iterator in everyday C# — nothing here suggests writing the generated class by hand.<>1__state, <CountUpBy>d__0) are implementation details, not a contract — they can and do change between compiler versions.foreach desugars to GetEnumerator() + a try/finally-wrapped while (MoveNext()) loop — always, guaranteed disposal.yield return compiles your method body into a class holding a state field, one field per surviving local/parameter, and a rewritten MoveNext().Reset() is dead weight on virtually every modern enumerator — call GetEnumerator() again instead.Where, Select, and nearly every LINQ-to-Objects operator are exactly this pattern — which is precisely why they're lazy.
IEnumerable<T> promises GetEnumerator(); IEnumerator<T> promises Current, MoveNext(), a largely-vestigial Reset(), and (via IDisposable) a guaranteed cleanup hook.foreach is compiler sugar for GetEnumerator() followed by a try/finally-wrapped while (MoveNext()) loop — Dispose() always runs, even on early exit.yield return method compiles into a hidden class with a numeric state field, one field per captured local/parameter, and a rewritten MoveNext() that resumes exactly where the last call paused.MoveNext() is called — including validation code, as lesson 121 covered and this lesson explains mechanically.Where, Select, and nearly every LINQ-to-Objects operator are built exactly this way — which is the real, mechanical reason they're lazily evaluated, the subject of lesson 199 next.You've opened up the hidden class behind yield return and traced exactly how it resumes. Let's check your understanding.
1. What does the compiler-generated numeric "state field" inside a yield return state machine actually track?
Correct: B
Why B is correct: As shown in the Simple Example's hand-written equivalent, <>1__state is checked by a switch at the top of MoveNext() to jump directly to the exact line after the last yield return — it's a resume point, not a counter.
Why A is incorrect: Nothing in the state machine inherently counts total items produced — the state field only ever encodes "where," never "how many."
Why C is incorrect: The current yielded value has its own separate field (<>2__current in the example), distinct from the state field.
Why D is incorrect: Disposal isn't tracked through the same state field in the way this answer implies — it's a separate concern handled by Dispose() and any finally blocks wired into it.
Reinforcement: The state field is a resume pointer — the entire "pause and resume" trick depends on it alone.
2. Why does a local variable used across multiple yield return statements need to become a field on the generated class, instead of staying an ordinary local variable?
Correct: B
Why B is correct: As explained in How It Works (Part 2), each yield return compiles to a return true — a genuine method return. Anything that needs to persist across that return must live somewhere other than the stack frame that just unwound, which is exactly what promoting it to a field on the state-machine object accomplishes.
Why A is incorrect: Local variables are entirely legal inside iterator methods — the compiler simply relocates the ones that must survive across yields.
Why C is incorrect: Speed isn't the motivation at all — it's a hard correctness requirement, not a performance choice.
Why D is incorrect: This is a functional necessity, not a stylistic one — without it, resuming a paused iterator correctly would be impossible.
Reinforcement: Fields on the state machine exist specifically to survive what would otherwise be a method return between every single yield return.
3. A second MoveNext() call is made on a yield return-generated enumerator. What actually happens?
Correct: B
Why B is correct: As the Simple Example's CountUpBy walkthrough showed explicitly, the second call's switch (<>1__state) jumps straight to StateOne — past the while check, directly to current += step; — never re-executing the initialization that ran on the first call.
Why A is incorrect: This is exactly the Common Confusion misconception this lesson calls out directly — the method does not restart; it resumes.
Why C is incorrect: Calling MoveNext() repeatedly is the entire normal, expected usage pattern — foreach itself does exactly this in a loop; it's Reset() that throws, not repeated MoveNext() calls.
Why D is incorrect: Resumption happens at the exact statement after the last yield return, which may be well past any loop condition check, not limited to re-checking the condition alone.
Reinforcement: "Resume from where you paused," not "start over" — that's the single idea the whole state machine exists to implement.
4. Why is products.Where(p => p.Stock > 0) guaranteed to invoke the predicate zero times the instant it's called, before any enumeration begins?
Correct: B
Why B is correct: As the Real-World Example traced directly, WhereIterator's body — the foreach that actually calls predicate(item) — becomes MoveNext(). Calling Where only builds the object holding source and predicate as fields; none of that body has executed yet.
Why A is incorrect: No such emptiness check happens at call time — the deferral has nothing to do with the contents of products at all.
Why C is incorrect: There's no compiler-level caching of LINQ call results — each call to Where simply builds a fresh state machine, as shown throughout this lesson.
Why D is incorrect: Delegates themselves aren't inherently lazy — invoking a Func<T,bool> directly runs it immediately. The laziness here comes specifically from Where's yield return-based implementation, not from any property of delegates in general.
Reinforcement: The predicate is just a field on the state machine until MoveNext() actually reaches the line that calls it.
5. A yield return iterator method contains a try/finally block that closes a file handle. A caller's foreach loop over this iterator exits early via break. Does the file handle still get closed?
Correct: B
Why B is correct: As both Part 1 (the foreach desugaring's try/finally) and Under the Hood point 2 explained, the two mechanisms compose deliberately: foreach always disposes the enumerator on exit, and the compiler routes any finally block from inside your iterator body into that same generated Dispose() — so cleanup runs even on early exit.
Why A is incorrect: This is exactly the guarantee the try/finally-wrapped foreach translation exists to prevent — cleanup is never silently skipped on early exit.
Why C is incorrect: The compiler-generated foreach code calls Dispose() automatically — no manual intervention from the caller is required or expected.
Why D is incorrect: The guarantee doesn't depend on timing relative to any particular yield return — it depends only on the resource being opened inside a try whose matching finally does the closing.
Reinforcement: IEnumerator<T> extending IDisposable, plus foreach's guaranteed try/finally, is precisely what makes resource cleanup inside an iterator reliable even under early termination.
You've opened the hood on IEnumerable<T> itself. Next: the interface that looks identical at the call site but works nothing like this underneath — IQueryable<T>.
dotnetmadeeasy.com — Learn C# and .NET, the right way.