Select Page

An iterator method lets a caller request values one at a time. The important design choice is when its work runs: calling the method returns a sequence, while enumeration runs the iterator body. That difference affects exceptions, repeated reads, and resource cleanup.

This tutorial follows a small ticket sequence through those decisions. You should already know methods and foreach; review C# methods and loops if needed. Each example is a separate complete Program.cs for a console application.

Tested with .NET SDK 10.0.402 targeting .NET 10 and C# 14. The language version follows the documented net10.0 default. These synchronous examples use BCL types and require no additional packages.

Watch execution stop at the first yield

A method returning IEnumerable<int> can use yield return to supply an integer and suspend. The next request resumes after that statement. Calling an iterator does not execute its body immediately; see the yield execution reference. Predict which messages appear when the consumer stops after one ticket:

using System;
using System.Collections.Generic;

IEnumerable<int> values = ReadTickets();
Console.WriteLine("Sequence returned");
foreach (int value in values)
{
    Console.WriteLine($"Consumer: {value}");
    break;
}
Console.WriteLine("Loop ended");

static IEnumerable<int> ReadTickets()
{
    Console.WriteLine("Iterator started");
    try
    {
        Console.WriteLine("Before first yield");
        yield return 101;
        Console.WriteLine("After first yield");
        yield return 102;
    }
    finally
    {
        Console.WriteLine("Cleanup");
    }
}

Expected standard output:

Sequence returned
Iterator started
Before first yield
Consumer: 101
Cleanup
Loop ended

Sequence returned appears first: the method call has not reached the iterator’s first message. The consumer receives 101, then breaks. After first yield never appears, and ticket 102 is never produced.

The finally block still runs. Exiting this foreach disposes the enumerator, which performs pending cleanup. The iterator disposal rules specify this behavior. A using inside an iterator similarly keeps its resource across suspensions and disposes it on completion or enumerator disposal. If you manually obtain an enumerator, dispose it yourself; merely abandoning one does not guarantee timely cleanup. The resource-lifetime example covers this pattern.

Repeating enumeration repeats work

The sequence below remembers the source array reference, rather than copying its elements at the method call. Each enumeration reads that array again. This is one reason Microsoft recommends making deferred behavior clear; the IEnumerable<T> interface alone does not tell callers whether a value is a completed collection or an iterator.

using System;
using System.Collections.Generic;
using System.Linq;

int[] tickets = { 101, 102 };
IEnumerable<int> sequence = ReadTickets(tickets);
Console.WriteLine("First: " + string.Join(", ", sequence));
tickets[0] = 201;
Console.WriteLine("Second: " + string.Join(", ", sequence));
int[] snapshot = sequence.ToArray();
tickets[0] = 301;
Console.WriteLine("Snapshot: " + string.Join(", ", snapshot));
Console.WriteLine("Third: " + string.Join(", ", sequence));

static IEnumerable<int> ReadTickets(int[] tickets)
{
    Console.WriteLine("Reading source");
    foreach (int ticket in tickets)
    {
        yield return ticket;
    }
}

Expected standard output:

Reading source
First: 101, 102
Reading source
Second: 201, 102
Reading source
Snapshot: 201, 102
Reading source
Third: 301, 102

There are four Reading source messages: the first two joins, ToArray, and the final join enumerate the iterator. Printing snapshot reads the array already built by ToArray. Its first element remains 201 after the original array changes to 301.

ToArray forces evaluation and returns an array of results. Here that gives a stable copy of integer values. It does not make the returned array immutable, and copying references to mutable objects would not clone those objects. Choose where to materialize deliberately: repeated enumeration can repeat reads and side effects, while materializing requires finishing the sequence before returning the array. An unending sequence cannot finish that operation.

For another eager collection boundary, see collection expressions and target typing. Spreading a sequence into a collection expression collects its elements.

Validate arguments before returning a lazy sequence

A guard at the top of an iterator body is deferred too. If your API should reject an invalid count at the call, put the guard in an ordinary method and return a separate iterator. The outer Tickets below contains no yield; its local Enumerate function does. Local functions can be iterators, allowing validation and element production to live together with different execution timing.

using System;
using System.Collections.Generic;

try
{
    IEnumerable<int> delayed = DelayedTickets(-1);
    Console.WriteLine("Delayed call returned");
    foreach (int ticket in delayed)
    {
        Console.WriteLine(ticket);
    }
}
catch (ArgumentOutOfRangeException)
{
    Console.WriteLine("Delayed error during enumeration");
}

try
{
    _ = Tickets(-1);
    Console.WriteLine("Validated call returned");
}
catch (ArgumentOutOfRangeException)
{
    Console.WriteLine("Validated error at call");
}
Console.WriteLine("Valid: " + string.Join(", ", Tickets(2)));

static IEnumerable<int> DelayedTickets(int count)
{
    ArgumentOutOfRangeException.ThrowIfNegative(count);
    for (int index = 0; index < count; index++)
    {
        yield return 101 + index;
    }
}

static IEnumerable<int> Tickets(int count)
{
    ArgumentOutOfRangeException.ThrowIfNegative(count);
    return Enumerate(count);

    static IEnumerable<int> Enumerate(int count)
    {
        for (int index = 0; index < count; index++)
        {
            yield return 101 + index;
        }
    }
}

Expected standard output:

Delayed call returned
Delayed error during enumeration
Validated error at call
Valid: 101, 102

The first invalid call returns successfully; its exception reaches the consumer when enumeration starts. The wrapper rejects the second invalid call immediately, so Validated call returned is absent. Valid input still produces values lazily. This arrangement changes validation timing; it does not move exceptions from later element production to the method call.

End production with yield break

Use yield break when a condition ends the whole sequence. In this example, -1 means the ticket stream is closed, so the later 103 must not be returned:

using System;
using System.Collections.Generic;

int[] tickets = { 101, 102, -1, 103 };
Console.WriteLine(string.Join(", ", UntilClosed(tickets)));

static IEnumerable<int> UntilClosed(IEnumerable<int> tickets)
{
    foreach (int ticket in tickets)
    {
        if (ticket == -1)
        {
            yield break;
        }
        yield return ticket;
    }
}

Expected standard output:

101, 102

Reaching the end of the iterator also finishes enumeration. To skip only one unwanted input and keep looking, use continue instead of terminating production. The two yield forms distinguish producing a value from ending the sequence.

A return value cannot replace the rest of an iterator

This separate example intentionally fails compilation:

using System;
using System.Collections.Generic;

foreach (int ticket in InvalidTickets())
{
    Console.WriteLine(ticket);
}

static IEnumerable<int> InvalidTickets()
{
    yield return 101;
    return new[] { 102 };
}

Expected compiler diagnostic: CS1622; there is no program output. An iterator cannot use an ordinary return with a value to supply its remaining results. Use further yield return statements, or yield break to finish. See the iterator diagnostic reference.

Choose the contract the caller needs

Use an iterator when callers benefit from consuming values as requested or stopping early. Use a completed collection when the API promises an already computed result or indexed access. Avoid relying on enumeration to perform an unrelated action exactly once: callers can enumerate repeatedly or never enumerate at all. For ordinary filtering and projection, the existing LINQ introduction explains the query tools that can consume sequences; custom iterator methods are useful when statement-by-statement production logic is clearer.

The output traces give a practical review checklist: identify the first enumeration, decide whether another enumeration should repeat the work, place argument checks at the intended boundary, and ensure the enumerator’s lifetime covers resource use.

Share This