Yield return vs return in C#: when to stream and when to deliver

Developers often treat return and yield return like twins, but they behave very differently. This guide explains the mental model behind each, why iterators are lazy, and how the compiler turns them into state machines. You will see small, runnable C# examples that highlight memory and performance tradeoffs. We also cover async iterators with IAsyncEnumerable and await foreach. Learn when to ship a crate of data and when to stream plates one by one.

If data were food, return would be the all‑you‑can‑eat buffet that dumps everything on your table at once, and yield return would be the sushi conveyor that sends plates only when you grab them. Both feed you, but the experience, performance, and memory story are wildly different. Let’s explore when to stream, when to deliver, and how to avoid the wasabi‑induced tears in between.

Two ways to hand back data

Think of this like passing around a list of Avenger IDs. With return, you do all the work up front.

Before code, the idea: build the list, fill it, return all of it. The caller pays once, then holds everything in memory.

static List<int> AssembleAvengers()
{
var ids = new List<int>();
for (var i = 1; i <= 5; i++) ids.Add(i);
return ids;
}

Now the streaming version. With yield return, you send one item at a time only when asked.

static IEnumerable<int> StreamAvengers()
{
for (var i = 1; i <= 5; i++)
yield return i;
}

Result: the second method uses less memory and can stop early if the caller bails out.

Laziness in action

Iterator methods are lazy. They do nothing until you start iterating. Let’s prove it with some logging.

static IEnumerable<int> LazyJedi()
{
Console.WriteLine("Booting lightsaber");
for (int i = 1; i <= 3; i++)
{
Console.WriteLine($"Yielding {i}");
yield return i;
}
}

When you grab only the first item, only the startup and first yield happen.

var first = LazyJedi().First();
Console.WriteLine($"First was {first}");

If you break early during a foreach, the iterator stops right there.

foreach (var id in StreamAvengers())
{
Console.WriteLine(id);
if (id == 3) break;
}

Under the hood without the headache

The compiler transforms an iterator method into a state machine that implements IEnumerable<T> and IEnumerator<T> behind the scenes. Every yield return becomes a checkpoint. Each call to MoveNext() advances to the next checkpoint. That is why:

  • Work runs only as you iterate
  • You can stop anytime
  • Exceptions inside the iterator are thrown when you reach the failing step, not when you create the sequence

A tiny demo of deferred exceptions:

static IEnumerable<int> Boom()
{
yield return 1;
throw new InvalidOperationException("Boom");
}
foreach (var n in Boom())
{
Console.WriteLine(n); // prints 1, then throws
}

Real world patterns that shine with yield

1) Stream huge files without melting RAM

Instead of loading an entire file, stream lines as needed.

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

Consume it and stop when you find what you want.

foreach (var line in ReadHolocron("quotes.txt"))
{
if (line.Contains("Mordor")) break;
Console.WriteLine(line);
}

2) Build composable pipelines

Because iterators are lazy, you can chain LINQ and only pay for what you read.

IEnumerable<int> evens = StreamAvengers().Where(i => i % 2 == 0);
Console.WriteLine(evens.Count());
Console.WriteLine(evens.Count());

Note the subtlety: that counted twice. Each Count() re‑enumerates and re‑does work. If the source is expensive, materialize once.

var evensList = evens.ToList();
Console.WriteLine(evensList.Count);
Console.WriteLine(evensList.Count);

3) Server responses that can stream

Even if you are on ASP.NET, building an iterator that yields items to the response stream can reduce time to first byte and memory pressure. The technique mirrors the file example, just be mindful of flushing output and backpressure.

Async streaming without tears

You cannot use yield return in a regular async Task method. But C# 8 introduced async iterators, which return IAsyncEnumerable<T> and pair with await foreach. That gives you true asynchronous streaming IO.

static async IAsyncEnumerable<string> ReadHolocronAsync(string path)
{
using var reader = new StreamReader(path);
while (!reader.EndOfStream)
yield return await reader.ReadLineAsync();
}
await foreach (var line in ReadHolocronAsync("quotes.txt"))
{
if (line.Contains("Dwight")) break;
Console.WriteLine(line);
}

Choosing wisely

Use return when:

  • You need random access like indexing or sorting right away
  • The data set is small enough to hold comfortably in memory
  • You want a single, immediate computation with a simple API

Use yield return when:

  • You process large or infinite sequences
  • You want to reduce memory allocations
  • You want to short‑circuit early or stream results progressively
  • You are building composable pipelines

Handy extras

  • yield break stops the sequence immediately
static IEnumerable<string> QuoteFilter(IEnumerable<string> lines)
{
foreach (var l in lines)
{
if (l.Contains("Mordor")) yield break;
yield return l;
}
}
  • You cannot use ref or out parameters in iterator methods
  • yield return is not allowed inside a try that has a catch clause
  • Debugging iterators is easier if you log checkpoints and keep steps small

Wrap up

return and yield return both send data home, but one ships a crate and the other ships a stream. When your app needs lower memory use, quicker first results, or early exits, iterators are your friend. When you want the whole thing ready to sort, index, or cache, plain return keeps things simple. Choose the right plate for the right bite and your code will stay fast, friendly, and far less hangry.