Iterator Pattern in C#: yield, IEnumerable, IAsyncEnumerable
Iterator pattern in C# / .NET 10: how yield return and IEnumerable are this pattern, and when IAsyncEnumerable lets you stream remote data lazily.
Table of contents
- What problem does the Iterator pattern solve in C#?
- How does yield return give you Iterator for free?
- When should you use IAsyncEnumerable in .NET 10?
- When does it pay to implement IEnumerable manually?
- How does Iterator compare to Composite and Visitor?
- What does a real .NET 10 example look like?
- Where should you read next in this series?
The order-fulfilment job needs to walk every order placed in the
last 24 hours, ship the ones that are paid, and email the ones that
are unpaid for too long. The "walk every order" part has three
common shapes: load them all into memory and foreach; page through
them in chunks of 1,000; stream them as they arrive from a remote
service. All three shapes have one thing in common — the consumer
just wants to write foreach (var order in source) and not care
about pagination, allocation, or async details.
The Iterator pattern is the answer the language has built in.
Define a uniform "give me the next element" surface; let the source
hide its internals (array, tree, paged API, database cursor).
Modern C# has been doing this since version 1 with IEnumerable<T>
and since C# 8 with IAsyncEnumerable<T>. This article spends its
time on the modern version of the pattern, because the textbook
hand-written iterator class is the version you will almost never
write.
What problem does the Iterator pattern solve in C#?
The pattern earns its place when callers want a uniform "one element at a time" surface but the source's internal layout varies or is hidden. Three concrete shapes:
- Hide internal layout. The cart is a tree
(Composite); the catalogue is a
list; the order log is a paginated API. Callers should write the
same
foreachagainst all three. - Lazy evaluation. Reading 5 GB of orders into a
List<>crashes the box. An iterator yields one row at a time and the GC reclaims it. - Composable transformations.
orders.Where(...).Take(100)is composed lazily — none of the source is evaluated until the consumer asks for the first element. The pattern enables this.
What is not an Iterator problem: "I want to fan out work to multiple workers" — that is closer to Mediator or message buses. Iterator is single-consumer, sequential.
How does yield return give you Iterator for free?
The C# yield keyword turns a regular method into a state-machine
that implements IEnumerable<T>:
public IEnumerable<Order> RecentOrders()
{
foreach (var page in _api.PagedOrders(pageSize: 1000))
{
foreach (var order in page.Items)
yield return order;
}
}
The caller writes:
foreach (var order in RecentOrders())
{
if (order.IsPaid) await _shipping.ShipAsync(order);
}
What the compiler generates: a class <RecentOrders>d__0
implementing IEnumerator<Order> with a _state field, a
MoveNext that resumes from each yield, and Dispose to clean up
the inner foreach. You wrote ten lines of straight-line code; the
compiler wrote the textbook Iterator pattern.
The structural picture (what foreach actually does):
sequenceDiagram
participant C as Caller (foreach)
participant E as Enumerator
C->>E: GetEnumerator()
loop until done
C->>E: MoveNext()
E-->>C: true / false
C->>E: Current
E-->>C: Order #N
end
C->>E: Dispose()
MoveNext runs your method up to the next yield return; Current
returns the yielded value. The loop ends when MoveNext returns
false (the method returned).
When should you use IAsyncEnumerable in .NET 10?
IAsyncEnumerable<T> adds the same shape over async sources. The
keyword combination is await foreach plus await yield return:
public async IAsyncEnumerable<Order> RecentOrdersAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
var pageToken = (string?)null;
do
{
var page = await _api.GetOrdersPageAsync(pageToken, ct);
foreach (var order in page.Items)
yield return order;
pageToken = page.NextToken;
}
while (pageToken is not null);
}
Caller:
await foreach (var order in RecentOrdersAsync().WithCancellation(ct))
{
if (order.IsPaid) await _shipping.ShipAsync(order, ct);
}
Three things this gives you:
- Backpressure. The producer pauses naturally between pages —
the consumer's body must finish before the next
MoveNextruns. - Cancellation.
[EnumeratorCancellation]plusWithCancellation(ct)is how the BCL recommends propagating a token through an async iterator. - Composition. EF Core 6+ exposes
DbContext.Set<T>().AsAsyncEnumerable(); ASP.NET Core controllers can returnIAsyncEnumerable<T>and the framework streams the response. The pattern is part of the framework now, not just a syntactic novelty.
When does it pay to implement IEnumerable manually?
Almost never. Two cases where it does:
- Custom collection types. A domain-specific data structure
(lock-free skip list, custom tree, ring buffer) where consumers
expect
foreach. The struct enumerator can avoid allocations better than the compiler-generated state machine. - Multiple parallel iterators. A type where two consumers can
walk the collection simultaneously without seeing each other's
state. The state machine generated by
yieldis not designed for this; explicit enumerator classes are.
For everything else — paged APIs, in-memory transformations,
streamed file reads — yield is the right answer.
How does Iterator compare to Composite and Visitor?
| Pattern | What is uniform? | Direction |
|---|---|---|
| Iterator | One element at a time, externally driven | Caller pulls |
| Composite | Operations work on leaves and groups | Recursive (often combined with Iterator) |
| Visitor | Operations added to fixed node types | Visitor pushes |
The split: Iterator pulls one element at a time; Visitor walks the whole structure performing an operation; Composite gives them both a uniform tree shape to work on. A typical .NET app uses all three: a Composite cart, an Iterator that yields its leaves, and a Visitor that computes tax across them.
What does a real .NET 10 example look like?
Combining yield, IAsyncEnumerable, and IEnumerable for the
same domain:
public sealed record Order(string Id, decimal Total, bool IsPaid);
public interface IOrderSource
{
// Synchronous, in-memory
IEnumerable<Order> AllInMemory();
// Async, paginated remote
IAsyncEnumerable<Order> StreamRecent(CancellationToken ct = default);
}
public sealed class HybridOrderSource : IOrderSource
{
private readonly List<Order> _local = LoadFromCsv();
private readonly IPaginatedApi _remote;
public HybridOrderSource(IPaginatedApi remote) => _remote = remote;
public IEnumerable<Order> AllInMemory()
{
foreach (var order in _local)
yield return order;
}
public async IAsyncEnumerable<Order> StreamRecent(
[EnumeratorCancellation] CancellationToken ct = default)
{
string? token = null;
do
{
var page = await _remote.GetPageAsync(token, ct);
foreach (var order in page.Items)
yield return order;
token = page.NextToken;
}
while (token is not null);
}
private static List<Order> LoadFromCsv() => /* ... */ new();
}
// Caller A — synchronous
foreach (var order in source.AllInMemory().Where(o => o.IsPaid))
Console.WriteLine($"local paid: {order.Id}");
// Caller B — async
await foreach (var order in source.StreamRecent(ct))
if (order.IsPaid)
await _shipping.ShipAsync(order, ct);
The consumer code is identical in shape (foreach / await foreach). The producer hides whether the source is in memory, on
disk, or across the network. That uniformity is the value of the
Iterator pattern.
Where should you read next in this series?
- Previous: Interpreter — when the thing you walk is an AST and each node knows how to evaluate.
- Next: Mediator — when many peers must talk through a hub instead of directly.
- Cross-reference: Composite — the tree your iterator usually walks.
- Cross-reference: Visitor — when the walk needs to perform multiple operations and you want to keep them out of the node classes.
- Decision tree: How to choose the right design pattern.
A pragmatic note: yield return is the most-used design pattern
in .NET that nobody calls a design pattern. Every LINQ query you
chain, every IEnumerable<T> you iterate, every async stream is
the Iterator pattern in action. The pattern's success is exactly
why nobody talks about it any more — it has dissolved into the
language.
Frequently asked questions
How does yield return implement the Iterator pattern?
yield return in a method that returns IEnumerable<T>, it generates a state-machine class behind the scenes — exactly the Iterator pattern's enumerator. Each yield return becomes a paused state; each MoveNext resumes from where the last yield stopped. You write straight-line code; the compiler writes the iterator class.When should I use IAsyncEnumerable instead of IEnumerable?
IAsyncEnumerable<T> when each element is produced by an async operation — paginated API calls, streamed database queries, message-bus consumers. You get backpressure, cancellation, and await foreach syntax. Use IEnumerable<T> when each step is synchronous and the source is in-memory or a local file.When should I implement IEnumerator manually instead of using yield?
IDisposable correctly. Hand-rolled IEnumerator<T> is only worth it for custom collection types that need to expose multiple parallel iterators with shared state, or for edge-case performance work where the generated state machine adds measurable overhead.How does the Iterator pattern interact with the Composite pattern?
cart.Flatten() method that yields every leaf is Iterator on top of Composite. The Composite.Flatten() extension method in our Composite chapter is exactly this combination.