Background

In modern enterprise applications, performance and memory efficiency are critical. Whether you’re building APIs, processing large datasets, or working with real-time streams, you often need to return sequences of data without loading everything into memory upfront. This is where iterators and specifically, the yield return keyword in C# come into play.

Introduced in C# 2.0 and continuously enhanced in the .NET ecosystem, yield return allows developers to write cleaner, more memory-efficient, and lazy-executed code. With .NET 9 and C# 13, it remains one of the most elegant tools for sequence generation and data streaming.

Problem Statement

Consider a scenario: you need to return a collection of records from a database or generate a sequence of computed values. The naive approach would be to build a List<T> or array in memory, populate it, and then return it.

public IEnumerable<int> GetNumbersNaive(int max)
{
    var list = new List<int>();
    for (int i = 0; i <= max; i++)
    {
        list.Add(i);
    }
    return list;
}

This works—but at the cost of:

  • Allocating memory for the entire collection upfront.
  • Delaying results until the entire list is built.
  • Adding unnecessary overhead when consumers may not even need all the results.

Why We Need yield return

The yield return keyword provides an iterator-based approach to solving this problem. Instead of constructing the full collection, it allows values to be returned one at a time, lazily as the consumer iterates over them.

This leads to:

  • Reduced memory footprint (no need for intermediate collections).
  • Faster response times (results are streamed, not batched).
  • Improved readability (complex iterators expressed with simple syntax).

Introduction to yield return

At its core, yield return is a way to implement stateful iteration in C#. When you write a method using yield return, the compiler automatically creates a state machine that manages the iteration logic for you.

Simple Example

public IEnumerable<int> GetNumbers(int max)
{
    for (int i = 0; i <= max; i++)
    {
        yield return i; // returned one at a time
    }
}

Usage:

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

Output:

0
1
2
3
4
5

Notice: we didn’t need to build a List<int>—the values are generated as needed.

More Examples

Example 1: Filtering Sequences Lazily

public IEnumerable<int> GetEvenNumbers(int max)
{
    for (int i = 0; i <= max; i++)
    {
        if (i % 2 == 0)
            yield return i;
    }
}

Example 2: Breaking the Iteration Early

public IEnumerable<int> GetNumbersUntilFive()
{
    for (int i = 1; i <= 10; i++)
    {
        if (i > 5)
            yield break;   // stop iteration immediately
        yield return i;
    }
}

Example 3: Streaming Database Rows (Simulated)

public IEnumerable<string> StreamDataFromDatabase()
{
    // Imagine this is a DbDataReader
    for (int i = 1; i <= 1000000; i++)
    {
        yield return $"Row {i}";
    }
}

Instead of loading a million rows into memory, results are streamed on demand.

Real-World Use Case: Building a Paging API with yield return

In enterprise systems—such as healthcare, finance, or ERP platforms—it’s common to expose APIs that return paged data. Returning large datasets at once is inefficient, and loading them all into memory can exhaust resources.

Traditionally, developers write something like this:

[HttpGet("patients")]
public async Task<IEnumerable<PatientDto>> GetPatients(int page, int pageSize)
{
    var patients = await _repository.GetPatientsAsync();

    return patients
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(p => new PatientDto(p));
}

This works, but it loads all patients into memory first, then filters them. Not scalable for millions of records.

Streaming with yield return

Instead, we can design a streaming solution:

[HttpGet("patients/stream")]
public async IAsyncEnumerable<PatientDto> GetPatientsStreamed(
    [FromQuery] int page = 1, 
    [FromQuery] int pageSize = 100,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    var offset = (page - 1) * pageSize;

    await foreach (var patient in _repository.StreamPatientsAsync(offset, pageSize, cancellationToken))
    {
        yield return new PatientDto(patient);
    }
}

Here’s what happens:

  1. Data is fetched in chunks directly from the database.
  2. Results are yielded one at a time to the client.
  3. Clients can start receiving data immediately—even before the full dataset is processed.

Repository with yield return

The repository method can be implemented like this:

public async IAsyncEnumerable<Patient> StreamPatientsAsync(
    int offset, 
    int pageSize, 
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    using var connection = new SqlConnection(_connectionString);
    await connection.OpenAsync(cancellationToken);

    var command = new SqlCommand(
        @"SELECT patient_id, first_name, last_name, dob 
          FROM Patients 
          ORDER BY patient_id 
          OFFSET @offset ROWS 
          FETCH NEXT @pageSize ROWS ONLY;", connection);

    command.Parameters.AddWithValue("@offset", offset);
    command.Parameters.AddWithValue("@pageSize", pageSize);

    using var reader = await command.ExecuteReaderAsync(cancellationToken);
    while (await reader.ReadAsync(cancellationToken))
    {
        yield return new Patient
        {
            PatientId = reader.GetInt32(0),
            FirstName = reader.GetString(1),
            LastName = reader.GetString(2),
            DateOfBirth = reader.GetDateTime(3)
        };
    }
}

Pros and Cons of yield return

Pros

  • Memory-efficient: Only one item is held at a time.
  • Lazy execution: Values are generated on demand.
  • Simplified code: Eliminates the need for manual iterator classes.
  • Composability: Works seamlessly with LINQ and other IEnumerable<T> operations.

Cons

  • No random access: Unlike lists, you can’t directly jump to the nth item.
  • Single-use enumerations: Once consumed, you need to re-enumerate.
  • Debugging can be harder: The state machine is compiler-generated.
  • Deferred execution pitfalls: May hit performance surprises if used incorrectly with expensive operations.

When to Use yield return

Use it when:

  • You want to stream data lazily (large files, database reads, APIs).
  • You need pipeline-style processing (process-as-you-go).
  • The consumer may not need the entire sequence.
  • You want cleaner code for iterators without extra boilerplate.

When Not to Use yield return

Avoid it when:

  • You need random access or indexed lookups.
  • You require multiple iterations over the same collection (unless you re-run the method).
  • Performance is critical and repeated computations per iteration are costly.
  • You need a materialized collection (then prefer ToList() or arrays).

Standing Points / Best Practices

  • Keep iterator methods pure—avoid side effects.
  • Avoid complex logic inside iterators; readability matters.
  • Document deferred execution to avoid surprises for consumers.
  • If multiple iterations are required, consider materializing with .ToList() or .ToArray().
  • Combine with LINQ for powerful query pipelines.

How It Works Under the Hood

When you use yield return, the compiler generates a hidden class implementing IEnumerable<T> and IEnumerator<T>. This hidden class keeps track of:

  • Current position (MoveNext)
  • State machine for iteration
  • Disposal logic

This is why yield return feels simple but is actually quite powerful—it abstracts away all the manual iterator boilerplate.

Final Thoughts

The yield return keyword in C# is not just syntactic sugar—it’s a powerful feature for writing efficient, lazy, and expressive code. It allows developers to design APIs and data-processing pipelines that are scalable and memory-efficient without sacrificing readability.

As a senior engineer or architect, I recommend adopting yield return in scenarios where streaming data and lazy evaluation provide tangible benefits. Just be mindful of deferred execution and ensure that consumers of your API understand when data is generated on demand.

In short: If you need iteration without overhead, think yield return.
It’s a tool every .NET developer should master.

More: https://dotnetcopilot.com/legacy-code-refactoring-in-modern-c-practical-strategies-for-clean-maintainable-systems/