DEV Community

Dominika Sikorska
Dominika Sikorska

Posted on • Originally published at blog.stackademic.com on

How yield return Reduces Memory by 90% in C#

The Problem: Materializing the World

We had a production incident at 2 AM. A memory leak was crashing our API every 6 hours, forcing restarts. The culprit? A seemingly innocent method that materialized 500,000 records into memory before filtering them.

One keyword fixed it: yield return. Memory usage dropped from 4GB to 80MB.

Imagine you need to process a dataset of 100,000 user records from a database or a large CSV file. You write a method to filter them based on some complex business logic.

The “standard” approach often looks like this:

public IEnumerable<User> GetActiveUsers(IEnumerable<User> allUsers)
{
  var result = new List<User>(); // Allocation!
  foreach (var user in allUsers)
  {
    if (user.IsActive && user.HasSubscription)
    {
      result.Add(user); // More allocation!
    }
  }
  return result;
}
Enter fullscreen mode Exit fullscreen mode

This code works, but it has a hidden cost. It materializes the entire filtered list in memory before returning a single item to the caller. If allUsers is huge, or if you only end up needing the first 5 results, you have wasted significant CPU and memory building a list you might not fully use.

In my previous article on [Deferred Execution: The Essence of LINQ in C#], I explained why LINQ queries wait to execute until you iterate them. Today, we’re going to look at how you can implement that same powerful behavior in your own methods using yield return.

What is yield return?

The yield return statement is C# syntactic sugar that allows you to create stateful iterators without the boilerplate of implementing the IEnumerable and IEnumerator interfaces manually.

When the compiler sees yield return, it essentially says: “I will pause here, return this value to the caller, and remember exactly where I left off.”

It transforms your method into a state machine. The method doesn’t run from top to bottom in one go; it runs in steps, advancing only when the caller asks for the next item.

Visualizing Control Flow

The best way to understand yield return is to see the “handshake” between the caller (the loop) and the iterator (the method).

Let’s look at this simple example:

public IEnumerable<int> GetNumbers()
{
  Console.WriteLine("Iterator: Start");
  yield return 1;
  Console.WriteLine("Iterator: Resuming after 1");
  yield return 2;
  Console.WriteLine("Iterator: Resuming after 2");
  yield return 3;
  Console.WriteLine("Iterator: Finished");
}

// Caller
void Main()
{
  Console.WriteLine("Caller: Starting loop");
  foreach (var number in GetNumbers())
  {
    Console.WriteLine($"Caller: Received {number}");
  }
  Console.WriteLine("Caller: Finished loop");
}
Enter fullscreen mode Exit fullscreen mode

The Output:

Caller: Starting loop
Iterator: Start
Caller: Received 1
Iterator: Resuming after 1
Caller: Received 2
Iterator: Resuming after 2
Caller: Received 3
Iterator: Finished
Caller: Finished loop
Enter fullscreen mode Exit fullscreen mode

Notice the pattern? The execution interleaves. The iterator runs until it hits a yield, hands control back to the caller, and waits. It doesn’t calculate “2” until the caller actually asks for it.

This interleaving pattern is why LINQ queries can filter 1 million records without allocating memory for all of them upfront.

Why This Matters: 3 Key Use Cases

1. Memory Efficiency (Lazy Evaluation)

The most common use case is processing large data streams. By using yield return, you process one item at a time. You never hold the entire collection in memory.

// Memory-efficient: Only one line is in memory at a time
public IEnumerable<string> ReadLargeLogFile(string path)
{
  using (var reader = new StreamReader(path))
  {
    string line;
    while ((line = reader.ReadLine()) != null)
    {
      if (line.Contains("ERROR"))
      {
        yield return line;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you used a List here, reading a 4GB log file would crash your application with an OutOfMemoryException. With yield return, you can process files of any size with memory usage capped at ~80MB (one buffer worth of data).

2. Infinite Sequences

Because yield return creates values on demand, you can define sequences that technically never end, allowing the caller to decide when to stop.

public IEnumerable<DateTime> GenerateDailySchedule(DateTime start)
{
  var current = start;
  while (true)
  {
    yield return current;
    current = current.AddDays(1);
  }
}

// Usage: Get just the next 7 days
var nextWeek = GenerateDailySchedule(DateTime.Now).Take(7);
Enter fullscreen mode Exit fullscreen mode

3. Custom Iteration Logic

Sometimes you need to iterate over a data structure that isn’t a simple list — like a tree, a graph, or a paginated API response.

public IEnumerable<Page> GetAllPagesFromApi()
{
  var currentPage = 1;
  while (true)
  {
    var data = FetchPageFromApi(currentPage);

    if (data.IsEmpty)
    {
      yield break; // Stop iteration
    }

    yield return data;
    currentPage++;
  }
}
Enter fullscreen mode Exit fullscreen mode

Under the Hood: The State Machine

When you compile code containing yield return, the C# compiler (Roslyn) performs some magic. It doesn’t compile your method as a standard method. Instead, it generates a private nested class that implements IEnumerator.

This generated class tracks:

  • State: Where is the execution currently? (Before the first yield? After the second yield?)
  • Context: The values of local variables and parameters.

This complexity is why yield return cannot be used in anonymous methods or methods containing unsafe code blocks — the compiler needs a stable scope to build this state machine.

Important Considerations

While yield return is powerful, it introduces the same trade-offs we discussed in my article on [Multiple Enumerations in LINQ Expressions]:

Deferred Execution : The code inside the method doesn’t run when you call the method; it runs when you iterate the result.

Multiple Enumeration: If you iterate the result twice (e.g., count = data.Count(); then foreach(var x in data)), you re-execute the entire method body from the start. This can be disastrous if the method performs expensive operations like database queries or API calls.

The Golden Rule : If you are using yield return to stream data, consume it once. If you need to access the data multiple times, materialize it using .ToList() or .ToArray() explicitly.

Conclusion

yield return is the engine behind LINQ’s efficiency. It allows you to write clean, declarative code that separates how you get the data from how you use the data, while keeping your memory footprint minimal.

By mastering custom iterators, you move beyond just using LINQ and start building your own memory-efficient data pipelines.

What’s Next?

If you found this useful, I’m building a series on LINQ internals and .NET performance patterns. Follow me for the next article where we’ll explore IAsyncEnumerable.

Have you used yield return in production? Drop a comment with your use case — I’d love to hear how you’re applying this pattern.


Top comments (0)