Iterators: yield return and yield break semantics, compiler-generated state machines, disposal and finally, restrictions (no ref params, no yield in catch), async iterators, and cost versus materialised collections.
A method that uses yield return is an iterator, and the compiler rewrites it into a state machine class that implements IEnumerable<T> and IEnumerator<T>. Locals become fields, a state field remembers where it paused, and each MoveNext runs to the next yield return. yield break ends the sequence. Execution is lazy, and finally and using blocks run when iteration finishes or the consumer disposes early. Iterators can't have ref or out parameters or yield inside a catch, and async iterators add await. They're ideal for streaming and large or infinite sequences.
An iterator is a method (or property/operator) using yield return/yield break that returns IEnumerable<T>/IEnumerator<T> (or IAsyncEnumerable<T>). The compiler rewrites it into a state machine class that produces values lazily, one per MoveNext(). I'd cover the keywords, the generated state machine, lazy evaluation, try/finally and using, restrictions, async iterators, and performance trade-offs.
yield return and yield breakpublic static IEnumerable<OrderLine> ParseLines(TextReader reader)
{
string? line;
while ((line = reader.ReadLine()) is not null)
{
if (line.StartsWith("#")) continue; // skip comments
if (line == "END") yield break; // stop early
yield return OrderLine.Parse(line); // produce one item, pause here
}
}
yield return x produces x and pauses the method until the next MoveNext().yield break ends the sequence.yield return, with all locals intact.The compiler generates a private class implementing IEnumerable<T> and IEnumerator<T> with:
MoveNext() containing the method body transformed into a switch on state,Dispose() that runs pending finally blocks,GetEnumerator() that returns this the first time (same thread) or a fresh clone afterwards, so each enumeration restarts from the beginning.Calling ParseLines(reader) only allocates this object — no code in the body runs until enumeration.
First(), Take(10), break), and the rest of the body never runs.while (true) yield return ...).try/finally and using inside iteratorspublic static IEnumerable<string> ReadLines(string path)
{
using var reader = new StreamReader(path); // disposed when enumeration completes OR the consumer disposes early
string? line;
while ((line = reader.ReadLine()) is not null) yield return line;
}
finally blocks (including those generated by using) run when iteration completes, when an exception escapes, or when the consumer disposes the enumerator (which foreach does in its own finally, including on break).Dispose(), the file stays open until GC.yield return inside a catch block or inside a finally block; you can yield return inside a try that has only a finally (and since C# 13 some restrictions around try/catch remain — you can't yield inside a try that has a catch).ref, out or in parameters (they'd need to outlive the call).IEnumerable, IEnumerable<T>, IEnumerator, IEnumerator<T>, or async equivalents.return with a value (only yield return/yield break).ref locals or ref struct locals (like Span<T>); C# 13 allows them as long as they don't cross a yield return.public async IAsyncEnumerable<Customer> StreamCustomersAsync([EnumeratorCancellation] CancellationToken ct = default)
{
string? continuation = null;
do
{
var page = await _api.GetCustomersAsync(continuation, ct);
foreach (var c in page.Items) yield return c;
continuation = page.Continuation;
} while (continuation is not null);
}
await foreach (var c in client.StreamCustomersAsync().WithCancellation(ct)) { ... }
They combine await and yield return, producing an IAsyncEnumerable<T> — perfect for paging APIs and streaming database results without loading everything.
MoveNext/Current, and no inlining across the boundary. In tight loops over small in-memory collections, a plain loop or returning an array/span is faster.Span<T>-based APIs.A nightly export loaded 3 million rows into a List<T> before writing CSV, peaking at several GB and triggering container OOM kills. Rewriting the data access as an async iterator (AsAsyncEnumerable() from EF Core with AsNoTracking) and writing each row to the response stream as it arrived kept memory flat at tens of MB. We made sure the iterator used await using for the connection so early cancellation released resources.
Checks a core advanced C# mechanism.
Green flags: state machine with state and current fields; yield return versus yield break; lazy evaluation; finally on Dispose; restrictions; async iterators; trade-offs.
Red flags: thinks yield returns a list; does not know cleanup semantics; cannot explain why validation is deferred.
private sealed class <ParseLines>d__0 : IEnumerable<OrderLine>, IEnumerator<OrderLine>
{
private int <>1__state; // -2: not started (enumerable), 0: start, 1: after yield, -1: finished
private OrderLine <>2__current;
private int <>l__initialThreadId;
private TextReader reader; // parameter copy
private string? <line>5__1; // hoisted local
public bool MoveNext()
{
switch (<>1__state)
{
case 0: <>1__state = -1; goto loop;
case 1: <>1__state = -1; goto loop; // resume after yield
default: return false;
}
loop:
while ((<line>5__1 = reader.ReadLine()) != null)
{
if (<line>5__1.StartsWith("#")) continue;
if (<line>5__1 == "END") return false;
<>2__current = OrderLine.Parse(<line>5__1);
<>1__state = 1;
return true;
}
return false;
}
}
With try/finally, states encode "inside try" so Dispose() knows which finally blocks to run.
The same object implements both interfaces. If GetEnumerator() is called on the thread that created it and it hasn't started, it returns this (saving an allocation); otherwise it creates a new instance with copied parameters. That's why enumerating twice restarts the method.
Async iterators generate a class implementing IAsyncEnumerable<T>, IAsyncEnumerator<T>, and IValueTaskSource<bool>/IValueTaskSource, using ManualResetValueTaskSourceCore<bool> to avoid allocations per MoveNextAsync. Cancellation flows via [EnumeratorCancellation]; DisposeAsync runs finally blocks, including await using disposals.
Exceptions thrown in the body propagate out of MoveNext() to the consumer, at enumeration time. You can't wrap yield return in try/catch, so error handling for per-item failures usually goes in a helper that returns a result (e.g. TryParse) and the iterator yields valid items or error records.
| Need | Choose |
|---|---|
| Streaming large/unbounded data | Iterator / async iterator |
Multiple passes, Count, indexing |
Materialised List<T>/array |
| Hot tight loop over small data | Array/span, plain loops |
| Lazy composition with LINQ | Iterator |
foreach.using inside an iterator and break after the first item; confirm disposal.using System.Runtime.CompilerServices;
namespace Advanced.Iterators;
public sealed record LogEntry(DateTimeOffset Timestamp, string Level, string Message);
public static class LogReader
{
// Streams a huge file one line at a time
public static IEnumerable<LogEntry> Read(string path)
{
ArgumentException.ThrowIfNullOrEmpty(path);
return Iterator(path);
static IEnumerable<LogEntry> Iterator(string path)
{
using var reader = new StreamReader(path);
try
{
while (reader.ReadLine() is { } line)
{
if (line.Length == 0) continue;
if (line == "--END--") yield break; // stop the sequence early
var parts = line.Split('|', 3);
if (parts.Length < 3) continue; // skip malformed lines
yield return new LogEntry(DateTimeOffset.Parse(parts[0]), parts[1], parts[2]);
}
}
finally
{
Console.WriteLine("log reader disposed"); // runs even if the consumer stops early
}
}
}
// Infinite sequence: always combine with Take/First
public static IEnumerable<int> RetryDelaysMs()
{
var delay = 100;
while (true)
{
yield return delay;
delay = Math.Min(delay * 2, 10_000);
}
}
// Async iterator
public static async IAsyncEnumerable<int> PollAsync(int times, [EnumeratorCancellation] CancellationToken ct = default)
{
for (var i = 1; i <= times; i++)
{
await Task.Delay(10, ct);
yield return i;
}
}
}
public static class Program
{
public static async Task Main()
{
var path = Path.GetTempFileName();
await File.WriteAllLinesAsync(path,
[
"2026-10-23T10:00:00+11:00|INFO|start",
"2026-10-23T10:00:01+11:00|ERROR|payment timeout",
"--END--",
"2026-10-23T10:00:02+11:00|INFO|never read",
]);
var firstError = LogReader.Read(path).First(e => e.Level == "ERROR"); // stops early, finally runs
Console.WriteLine(firstError.Message);
Console.WriteLine(string.Join(",", LogReader.RetryDelaysMs().Take(6))); // 100,200,400,800,1600,3200
await foreach (var n in LogReader.PollAsync(3)) Console.WriteLine($"poll {n}");
}
}
What happens: validation or setup in iterator bodies. Why it is wrong: runs only on enumeration. Do instead: validate eagerly in a wrapper; iterate in a local function.
What happens: Count() then foreach over an iterator reading a file or database.
Why it is wrong: re-runs I/O each time.
Do instead: materialise once if you need multiple passes.
What happens: GetEnumerator()/MoveNext() without Dispose.
Why it is wrong: finally/using blocks don't run; resources leak.
Do instead: use foreach or using var e.
What happens: iterators keeping database connections open while consumers do slow work per item. Why it is wrong: connection pool exhaustion and long-running transactions. Do instead: buffer appropriately or keep per-item work fast.
What happens: iterator methods over tiny collections called millions of times. Why it is wrong: allocation and interface call overhead. Do instead: arrays, spans or struct enumerators.
yield inside catchWhat happens: wrapping yield return in try/catch.
Why it is wrong: not allowed.
Do instead: move error handling into helpers or yield result objects.
[EnumeratorCancellation]What happens: async iterators ignore WithCancellation tokens.
Why it is wrong: can't cancel streaming operations.
Do instead: annotate the token parameter.
What happens: iterators that update state or send messages as they yield. Why it is wrong: effects depend on how far and how often consumers enumerate. Do instead: keep iterators pure or make effects explicit.
Iterators are the engine behind LINQ and streaming APIs. Seniors know the state-machine lowering well enough to predict behaviour: lazy start, recomputation on each enumeration, and cleanup through Dispose. I use them for streaming large or unbounded data and pipelines, pair them with eager validation wrappers, and choose materialised collections when the result is small or needs to be enumerated more than once.
IEnumerable<T> is just a promise that you can get an enumerator. IEnumerator<T> gives MoveNext, Current and Dispose, and that is all: no count, no indexing, and possibly lazy, infinite or single-use. foreach is pattern-based and compiles to a while (MoveNext()) loop with Dispose in a finally. List<T> uses a struct enumerator, which gets boxed if you iterate through the interface. IAsyncEnumerable<T> is the async version, used with await foreach. I accept IEnumerable<T> when I only iterate once, and return IReadOnlyList<T> unless I mean to stream.
An iterator method runs none of its body until the first MoveNext, so a ThrowIfNull at the top only fires when someone enumerates. An async method stores the exception in the returned task, so it only shows up when awaited. The fix is to keep the public method non-iterator and non-async: validate there, then return a call to a local iterator or local async function. That's exactly how LINQ validates eagerly. Local functions can capture variables without allocating when they aren't turned into delegates, and marking them static forbids capture altogether. I use them for private helpers that only make sense inside one method.
LINQ to Objects is a library of extension methods on IEnumerable<T>. Where and Select take delegates and return iterator objects that pull items from their source only when you enumerate, so a query is a lazy pipeline. Query syntax is just compiled into those method calls. Operators like Where stream item by item, while OrderBy and GroupBy must buffer the source first. The BCL fuses common chains and uses Count when the source is a collection. Recent .NET versions added Chunk, MinBy, CountBy, Index, and in .NET 10 LeftJoin and RightJoin.
How to tell it (STAR)
**S:** Our log analysis tool crashed on large production logs during incidents, exactly when we needed it. **T:** Make it work on any file size. **A:** I replaced the load-everything approach with an iterator that yields one parsed entry at a time, so the pipeline streams and stops early. **R:** Memory stayed under [amount] for any file size and the first results appeared in seconds.
Last updated 30 Oct 2026.