Back to Blog
C#

c# yield return: Lazy Iteration Explained

Learn how c# yield return creates lazy iterators, when to use it, and how it affects memory and execution.

C#yielditeratorlazy evaluationIEnumerablestate machine
Diagram showing a lazy iterator generating values on demand with c# yield return

When you write a method that returns IEnumerable<T> and use yield return, the compiler transforms the method into a state machine. The method does not execute its body when called; it returns an iterator object that produces values one at a time as the consumer requests them. This is the core behavior of c# yield return and the foundation for lazy iteration in C#.

What Happens When You Call an Iterator Method

Consider a simple method that yields numbers:

public static IEnumerable<int> GetNumbers() { yield return 1; yield return 2; yield return 3; }

Calling GetNumbers() does not run the body. Instead, it returns an IEnumerable<int> that, when enumerated, runs the code up to the first yield return, returns that value, and pauses. The next MoveNext() resumes from that point. This state-machine behavior is generated by the compiler and is the reason yield return is fundamentally different from a method that builds and returns a List<int>.

The same principle applies when you combine yield return with loops or conditional logic. Each iteration of the loop resumes where it left off, preserving local variables across calls to MoveNext().

Writing a Practical Iterator Method

A common use case is filtering or transforming data without materializing an entire collection. For example, reading lines from a file and yielding only non-empty lines:

public static IEnumerable<string> ReadNonEmptyLines(string path) { using var reader = new StreamReader(path); string? line; while ((line = reader.ReadLine()) != null) { if (!string.IsNullOrWhiteSpace(line)) { yield return line; } } }

This method opens the file, reads one line at a time, and yields only the lines that pass the filter. The consumer can stop enumeration at any point, which means the file is not read fully if the caller only needs the first few matching lines. That is a direct consequence of deferred execution.

The using statement is important: the StreamReader is disposed when the iterator is fully enumerated or when the consumer disposes the enumerator. If the consumer abandons enumeration without disposing, the resource remains open until the garbage collector finalizes the enumerator. In practice, foreach disposes the enumerator automatically.

Deferred Execution and Side Effects

Because the body of an iterator method does not run until the first MoveNext() call, any side effects inside the method are also deferred. This can lead to surprising behavior if you are not careful.

public static IEnumerable<int> GetValues() { Console.WriteLine("Method body started"); yield return 10; Console.WriteLine("Resumed after first yield"); yield return 20; }

If you write:

var values = GetValues(); Console.WriteLine("Before enumeration"); foreach (var v in values) { Console.WriteLine(v); }

The output order is:

Before enumeration
Method body started
10
Resumed after first yield
20

The method body does not start until the foreach requests the first element. This is useful for expensive setup that should only run when the result is actually consumed, but it also means exceptions thrown before the first yield return are not thrown at the call site. They surface during enumeration. If you need to validate arguments eagerly, you must split the method into a public wrapper that validates and a private iterator that contains yield return.

Using yield break to End the Iteration

yield break terminates the iteration. It is the equivalent of returning from a normal method. You can use it to stop the sequence based on a condition.

public static IEnumerable<int> TakeWhilePositive(IEnumerable<int> source) { foreach (var item in source) { if (item <= 0) { yield break; } yield return item; } }

When yield break is reached, the iterator's MoveNext() returns false, and enumeration stops. This is different from return in a non-iterator method; return is not allowed inside an iterator block. You must use yield break to exit early.

Performance and Memory Behavior

The main advantage of yield return is that it avoids allocating a collection that holds all results at once. For large or infinite sequences, this is essential. However, there is a cost: the compiler generates a state machine class, and each enumeration creates a new instance of that class. For small, short-lived sequences, the overhead of the state machine may be higher than simply building a List<T>. The decision is not about raw speed; it is about memory footprint and the ability to stop early.

Consider a method that returns the first n Fibonacci numbers. With yield return, you can generate them lazily and stop after n elements without computing the rest. With a list, you would have to compute all of them upfront. For infinite sequences, yield return is the only practical option.

That said, if you always need the entire sequence and you enumerate it multiple times, materializing it into a list may be more efficient because each enumeration of an iterator re-executes the method body. For example, calling Count() and then iterating again will run the iterator twice. If the body is expensive, caching the result in a collection avoids repeated work.

Limitations and Compatibility

yield return cannot be used in methods with ref or out parameters, in async methods (before C# 8, and even then only with IAsyncEnumerable), or in methods that contain unsafe blocks. Also, you cannot use yield return in a method that has a try block with a catch that does not rethrow, because the state machine cannot represent that control flow reliably. The compiler enforces these restrictions.

For asynchronous iteration, C# 8 introduced IAsyncEnumerable<T> and await foreach, which work with yield return in async iterator methods. That is a separate feature and requires the method to return IAsyncEnumerable<T> and use yield return with await expressions.

Choosing Between yield return and Materialized Collections

The decision to use yield return or to build a List<T> depends on how the result is consumed.

Considerationyield returnMaterialized collection
Memory usageLow; values produced on demandHigh; all values stored in memory
Early terminationSupported; consumer can stop anytimeNot possible; all values computed
Multiple enumerationRe-executes the iterator each timeReuses the same collection
Side effectsDeferred until enumerationRun at method call time
Best fitLarge or infinite sequencesSmall, frequently reused results

If you need to pass the result to multiple methods that each enumerate it, a materialized list is usually better. If you are streaming data from I/O or generating a sequence that may be infinite, yield return is the right tool.

A common pattern is to expose a lazy iterator publicly but internally materialize it when you need to cache. For example, you can wrap the iterator with a List<T> using ToList() when you know the consumer will enumerate multiple times.

The key is to understand the tradeoff: yield return gives you lazy, on-demand production of values at the cost of re-execution on each enumeration and a small per-enumeration allocation. Materialized collections give you a snapshot of the data at a point in time, with the memory cost of storing everything.

c# yield return: Lazy Iteration Explained | RYUSLOG DEV