C# Iterators, Yield and Deferred Execution

Create C# iterators with yield return, predict when work and exceptions occur, control repeated enumeration, and understand cleanup during early exit.

Lesson 33 of 43Intermediate9 min read

A method can produce a sequence without constructing a list containing every value. An iterator pauses after delivering an element and resumes when the caller requests the next one. This is useful for generated sequences, filtered data and bounded streaming workflows. It also changes when the method's work happens: calling an iterator method is not the same as executing its body to completion.

The examples use .NET 8 and C# 12. You will build an iterator that emits visible tracing, stop enumeration early, and separate immediate argument validation from deferred generation. The emphasis is on predictable timing and ownership, rather than using yield simply to shorten a loop.

IEnumerable describes a sequence contract

IEnumerable<T> supplies an enumerator that can advance through values. It does not promise that those values are already stored, that counting is cheap, or that repeating the traversal has no side effects. A list is one implementation, but a generated sequence can compute each value as requested.

A method using yield return lets the compiler create the iterator machinery. Local state is preserved between advances. yield break ends the sequence explicitly, while reaching the method's end also completes it. The caller commonly drives the process through foreach. Keep the producer's contract honest: if generation performs I/O or expensive computation, document that behavior because a simple-looking enumeration may then have important cost or failure conditions.

Work happens when the consumer advances

For an iterator returning IEnumerable<T>, constructing the returned sequence does not normally run the iterator body's statements. The first advance begins the body. Each yield return delivers a value and pauses before subsequent statements. This means a diagnostic printed inside the iterator appears during traversal, not necessarily when the method is called.

Exceptions follow the same timing. An invalid argument check written inside the iterator can throw only when enumeration begins. If the API promises to reject an invalid argument immediately, put validation in a non-iterator wrapper that returns a separate iterator. This distinction affects try/catch placement and tests. A test that merely calls the method without advancing can miss a failure that a real consumer will encounter later.

Early exit still needs cleanup

foreach disposes its enumerator when it finishes or exits through break, allowing iterator finally blocks and applicable resource cleanup to run. A producer that owns a resource can therefore scope it around traversal. A caller manually handling an enumerator must also dispose it appropriately.

Do not return a deferred sequence that depends on a resource already disposed before enumeration starts. For example, constructing a query inside a short-lived resource scope and returning it can defer the actual read until the resource is gone. Either keep ownership alive through the iterator, or materialize the result before leaving the scope when that is the intended design. Resource lifetime follows the real execution, not the visual location of the method call.

Repeated enumeration can repeat the computation

Traversing a generated sequence twice can run its body twice. That can be correct for a pure mathematical sequence and surprising for a sequence that reads changing state. Calling Count() and then foreach may perform two complete traversals. A query over a database can observe changed data or perform another request rather than reuse the earlier values.

Materialize with an appropriate collection when the requirement is a stable snapshot and the result fits within a defined size. Materialization trades incremental processing for storage and performs the work immediately. Avoid calling ToList on an unbounded sequence. Prefer a single traversal when possible, and explicitly limit generated output. A streaming abstraction is useful only when the producer and consumer agree on limits and failure behavior.

Keep producer side effects understandable

A producer should avoid hidden state changes when merely enumerated. If it must perform effects, explain whether each traversal repeats them and what happens after partial consumption. Sending messages or charging a payment from a generic enumerable is especially difficult to reason about because consumers can stop, retry or enumerate twice.

For a pure iterator, tests should cover zero elements, one element, the configured maximum, early termination and repeated traversal. Use traceable resource fakes if cleanup is important. Assert when invalid arguments throw, not just that they eventually throw. For large or infinite sequences, require the caller to request a bounded portion and avoid operations that demand the entire sequence before producing a result.

Official references

C# Iterators, Yield and Deferred Execution Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Observe pause, resume and early disposal

This complete console program requests one value and stops. The trace reveals which statements execute and when cleanup occurs.

Values two and three are never produced because the consumer stops requesting elements. The cleanup message appears because foreach disposes the iterator during the early exit. This behavior supports bounded consumers, but does not excuse leaking a manually acquired enumerator.

C#
using System;
using System.Collections.Generic;

IEnumerable<int> sequence = TraceValues();
Console.WriteLine("Sequence created");
foreach (int value in sequence)
{
    Console.WriteLine($"Received {value}");
    break;
}

static IEnumerable<int> TraceValues()
{
    Console.WriteLine("Iterator started");
    try
    {
        for (int value = 1; value <= 3; value++)
        {
            Console.WriteLine($"Producing {value}");
            yield return value;
        }
    }
    finally
    {
        Console.WriteLine("Iterator cleaned up");
    }
}
Output
Sequence created
Iterator started
Producing 1
Received 1
Iterator cleaned up

C# Iterators, Yield and Deferred Execution in Real Projects

Worked exercise: validate now, generate later

The task is to generate a fixed count of order numbers, beginning at a supplied positive value. Invalid counts must fail at the call boundary. The wrapper validates before returning the iterator, and the iterator uses checked addition so a future range change cannot silently wrap.

C#
using System;
using System.Collections.Generic;

foreach (int number in OrderNumbers(40, 3))
    Console.WriteLine(number);

try
{
    _ = OrderNumbers(40, -1);
}
catch (ArgumentOutOfRangeException)
{
    Console.WriteLine("Count rejected immediately");
}

static IEnumerable<int> OrderNumbers(int start, int count)
{
    if (start <= 0)
        throw new ArgumentOutOfRangeException(nameof(start));
    if (count < 0 || count > 1000)
        throw new ArgumentOutOfRangeException(nameof(count));
    if (count > 0)
        _ = checked(start + count - 1);

    return Generate(start, count);
}

static IEnumerable<int> Generate(int start, int count)
{
    for (int offset = 0; offset < count; offset++)
        yield return checked(start + offset);
}
Output
40
41
42
Count rejected immediately

The wrapper contains no yield, so its checks execute normally. The separate generator owns the deferred loop. The final-number check catches an overflowing requested range before any values are returned, avoiding a partially successful sequence for this contract.

C# Iterators, Yield and Deferred Execution FAQ

Does calling an iterator run its loop immediately?

Normally not for an iterator returning IEnumerable<T>. The generated sequence begins executing when an enumerator advances. That includes many argument checks placed inside the iterator body. If immediate validation is part of the API, use an ordinary wrapper and return a separate iterator after validation. Tests should explicitly distinguish construction from the first and later advances.

Does yield return copy the complete result?

It delivers one element at a time and preserves enough state to continue. It does not automatically build a list of all elements. A consumer can choose to materialize the result, which then incurs collection storage. Memory use can still be large if the iterator retains large objects or each value is large, so incremental delivery is not a blanket guarantee of small memory usage.

What happens if foreach stops after one value?

The loop disposes its enumerator when it exits. An iterator's applicable finally cleanup runs, allowing producer-owned resources to be released. Statements that would have produced later values do not run merely to finish the sequence. Manual enumeration needs equivalent disposal discipline; acquiring an enumerator and abandoning it can postpone or miss intended cleanup.

Should I materialize every enumerable to avoid surprises?

No. Materialization is useful when a stable bounded snapshot is required, but it defeats incremental processing and can exhaust memory on a large or unbounded sequence. First understand the source and the consumer's needs. A single-pass algorithm often avoids repeated work without storing everything. Document whether traversal is repeatable and whether it observes changing external state.

C# Iterators, Yield and Deferred Execution Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choicemedium

A generated sequence reads changing external data. A caller counts it and then loops over it. What concern should be reviewed?

C# Iterators, Yield and Deferred Execution Quiz

2 questions to check what you learned.

  1. When does the body of a typical IEnumerable iterator begin?

    • At compilation.
    • As soon as its method returns the sequence object.
    • Only after every value has been requested.
    • When the consumer first advances the enumerator.
  2. Why put argument validation in a non-iterator wrapper?

    • To reject invalid arguments immediately before deferred generation begins.
    • To make validation inside the iterator execute when the sequence object is returned.
    • To prevent foreach from disposing the enumerator.
    • To force every sequence to be materialized.