DEV Community

The Hidden Cost of yield in C#: What the Compiler Doesn't Tell You

Most C# developers know how to use yield return. Few understand what actually happens after compilation. If you've ever written something like this:

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

it looks almost magical. No collection. No list allocation. No iterator implementation. Yet somehow the method returns an IEnumerable. So what is really happening? The answer is one of the most elegant compiler transformations in the entire .NET ecosystem. Let's open the hood.

The Illusion

Most developers imagine the previous code executes like this:

Call method
    โ†“
Return 1
    โ†“
Pause
    โ†“
Return 2
    โ†“
Pause
    โ†“
Return 3

That isn't what happens. C# methods cannot actually pause execution. Instead, the compiler completely rewrites your method into something entirely different.

The Compiler Creates a State Machine

Your tiny method becomes a hidden class similar to this:

private sealed class GetNumbersIterator : IEnumerable<int>, IEnumerator<int>
{
    private int _state;
    private int _current;

    public bool MoveNext()
    {
        switch (_state)
        {
            case 0:
                _current = 1;
                _state = 1;
                return true;
            case 1:
                _current = 2;
                _state = 2;
                return true;
            case 2:
                _current = 3;
                _state = -1;
                return true;
            default:
                return false;
        }
    }

    public int Current => _current;
}

Your original method no longer exists. Instead, it simply returns:

return new GetNumbersIterator();

Every yield return becomes another state inside MoveNext().

Why Local Variables Don't Disappear

Consider this code:

IEnumerable<int> Squares()
{
    int x = 1;
    while (x <= 3)
    {
        yield return x * x;
        x++;
    }
}

After the first yield, the method "pauses." But where is x stored? Not on the stack. The original stack frame has already disappeared. Instead, the compiler promotes local variables into fields:

private int _x;

The iterator object now owns every variable that must survive between iterations. This is why iterator methods can remember where they left off.

Heap Allocation Happens

Many developers assume yield is allocation-free because it doesn't create a list. That's only partially true. Instead of allocating an entire collection, the runtime allocates the iterator object itself.

Without yield:

List<int>
โ”œโ”€โ”€1
โ”œโ”€โ”€2
โ”œโ”€โ”€3

With yield:

Iterator Object
โ”‚   MoveNext()

One allocation replaces another. Usually that's a good tradeoff, but it is not free.

Lazy Execution Changes Everything

Nothing executes here:

var numbers = GetNumbers();

The method body hasn't even started. Execution begins only when enumeration starts:

foreach (var n in numbers)
{
    Console.WriteLine(n);
}

This surprises many developers. If your iterator contains logging:

IEnumerable<int> GetNumbers()
{
    Console.WriteLine("Started");
    yield return 1;
}

then this prints nothing:

var nums = GetNumbers();

Only this triggers execution:

nums.First();

Exceptions Behave Differently

Because execution is deferred:

var numbers = GetNumbers();

won't throw exceptions inside the iterator. Instead, the exception appears later:

foreach (var n in numbers)

This delayed behavior can make debugging surprisingly confusing.

Why yield Often Beats Returning a List

Imagine reading a million rows from a database. Returning a list means:

Read everything
    โ†“
Allocate huge list
    โ†“
Return list

Using yield becomes:

Read row
    โ†“
Return row
    โ†“
Read next row
    โ†“
Return row

Memory usage remains almost constant. This is one of the biggest reasons LINQ is so efficient.

But There Are Trade-offs

yield isn't always the right tool. Consider these limitations:

  • You cannot easily iterate multiple times over one enumerator.
  • Random access is impossible.
  • Deferred execution can hide exceptions.
  • Iterator objects still allocate memory.
  • Complex iterator logic can become difficult to debug.

Understanding these trade-offs helps you decide when laziness is an optimization-and when it becomes a source of complexity.

Visualizing the State Machine

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚   Start    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”˜
      โ”‚
      โ–ผ
  yield return 1
      โ”‚
      โ–ผ
  State = 1
  MoveNext()
      โ”‚
      โ–ผ
  yield return 2
      โ”‚
      โ–ผ
  State = 2
  MoveNext()
      โ”‚
      โ–ผ
  yield return 3
      โ”‚
      โ–ผ
  Finished

The compiler converts your seemingly linear method into a deterministic state machine that advances one step at a time.

Final Thoughts

yield is one of C#'s most elegant language features-not because it magically pauses methods, but because the compiler quietly rewrites your code into a sophisticated iterator object. Once you understand that:

  • yield creates a compiler-generated class
  • local variables become heap fields
  • execution is deferred until enumeration
  • and each yield return becomes a new state

the behavior of iterators suddenly becomes predictable.

The next time you write yield return, remember: you're not writing an iterator-you're asking the C# compiler to write one for you.

Comments

No comments yet. Start the discussion.