Most C# developers use IEnumerable and LINQ every single day without ever writing an iterator block by hand. The yield keyword is the reason foreach loops, LINQ operators and async streams can process sequences one item at a time instead of loading everything into memory first. Once you understand what the compiler does with yield, a lot of behaviour that looks like magic in LINQ starts making sense.
What the yield Keyword Actually Does
The yield keyword tells the compiler that the method containing it is an iterator block. An iterator block returns an IEnumerable (or IEnumerable<T>), and yield is how you hand back each value in that sequence. Under the hood, the compiler rewrites your method into a state machine class that implements IEnumerator, tracking where execution paused so it can resume from that exact point on the next call to MoveNext().
The part that trips people up is that this state machine is lazily evaluated. Calling a method that contains yield return does not execute a single line of your code. The method body only starts running when something enumerates the result, typically a foreach loop or a call to ToList() or ToArray(). This deferred execution is the same mechanism behind LINQ’s query composition.
The List-Building Approach You Already Know
Before looking at yield, it helps to see the pattern most of us reach for without thinking about it. You create a list, populate it in a loop, and return the whole thing once it is full.
var engineers = GetSoftwareEngineers();
public IEnumerable<SoftwareEngineer> GetSoftwareEngineers()
{
var result = new List<SoftwareEngineer>();
for (var i = 0; i < 10; i++)
{
result.Add(new SoftwareEngineer
{
Id = i
});
}
return result;
}
This works fine and returns a fully materialized list of ten SoftwareEngineer objects. The catch is that the entire collection is built and held in memory before the caller sees even the first item. For ten items that is irrelevant. For a method that streams rows from a large query result or paginated API response, it is wasted memory and wasted time before the caller gets anything back.
Rewriting It With yield return
You can express the same method without the intermediate list, by yielding each object as it is created.
var engineers = GetSoftwareEngineers();
public IEnumerable<SoftwareEngineer> GetSoftwareEngineers()
{
for (var i = 0; i < 10; i++)
{
yield return new SoftwareEngineer
{
Id = i
};
}
}
The output looks identical when you enumerate it in a foreach loop, but the two implementations are fundamentally different underneath. The first version materializes the full list eagerly, before returning. The second version returns an IEnumerable backed by a state machine that has not run yet. Nothing is materialized until you either iterate it in a foreach loop or force it with ToList() or ToArray(). If you forget this and call the method expecting a ready-made collection, you can end up re-running the whole loop body every time you enumerate the result, which is a common source of subtle bugs and duplicate database or API calls when the iterator wraps an expensive operation.
Stopping Iteration With yield break
yield break exits the iterator block immediately, the same way a return statement exits a normal method. You typically use it when a condition tells you there is nothing more worth producing, and you want to stop the sequence rather than skip individual items.
Console.WriteLine(string.Join(", ", TakeWhilePositive(new[] { 1, 2, -3, 4 })));
// Output: 1, 2
public IEnumerable<int> TakeWhilePositive(IEnumerable<int> numbers)
{
foreach (int num in numbers)
{
if (num > 0)
{
yield return num;
}
else
{
yield break;
}
}
}
The output is 1, 2 and nothing else, even though 4 is also positive. As soon as the method hits -3, yield break ends the iterator entirely, so the loop never gets to evaluate 4. This is the behaviour people mix up most often with yield break: it does not skip the current item and move on, it stops the sequence for good. If you actually want to skip a value and keep going, use continue instead, and reserve yield break for genuine early termination.
Streaming Async Data With IAsyncEnumerable
C# 8 added IAsyncEnumerable<T>, which lets you combine yield with async work so you can stream a sequence asynchronously instead of waiting for the whole thing to be ready. A common real-world case is fetching a list of users from the database and then calling an external service to enrich each one, for example pulling a profile picture URL from blob storage. Written the conventional way, you wait for every enrichment call to finish before the caller sees anything.
public async Task<IEnumerable<User>> GetUsersAsync()
{
var users = await GetUsersFromDbAsync();
foreach (var user in users)
{
user.ProfileImage = await GetProfileImageAsync(user.Id);
}
return users;
}
// Calling it:
var users = await GetUsersAsync();
foreach (var user in users)
{
Console.WriteLine(user);
}
Here the caller is blocked until every single profile image call has completed, even if there are hundreds of users. Rewriting the method to return IAsyncEnumerable<User> lets each user flow to the caller as soon as its own enrichment call finishes, instead of waiting for the batch.
public async IAsyncEnumerable<User> GetUsersAsync()
{
var users = await GetUsersFromDbAsync();
foreach (var user in users)
{
user.ProfileImage = await GetProfileImageAsync(user.Id);
yield return user;
}
}
// Calling it:
await foreach (var user in GetUsersAsync())
{
Console.WriteLine(user);
}
With await foreach, the caller starts processing the first user as soon as it is ready, while the enrichment call for the second user is still in flight. For a UI that renders a list progressively, or a background job that writes results to a queue as they arrive, this reduces perceived latency noticeably. The trade-off is that you lose the ability to run all the enrichment calls in parallel with something like Task.WhenAll, since IAsyncEnumerable processes items sequentially by design. If raw throughput matters more than streaming, a parallel batch approach will usually finish faster.
A Practical Use Case: Structural Equality in Value Objects
One place I reach for yield return often is Domain-Driven Design value objects. A value object is compared by its contents rather than by identity, so it needs a method that exposes every field participating in equality. yield return is a clean way to write that method without building an array or list just to hand it to an equality comparer.
public class Address
{
public string City { get; init; }
public string Street { get; init; }
public string Zip { get; init; }
public string Country { get; init; }
public IEnumerable<object> GetEqualityComponents()
{
yield return City;
yield return Street;
yield return Zip;
yield return Country;
}
}
A base ValueObject class can call GetEqualityComponents() from its Equals and GetHashCode overrides, comparing each component in sequence between two instances. Adding a new property to the value object only means adding one more yield return line, there is no array to resize or list to maintain. This pattern is common in libraries that implement DDD building blocks, and it reads more clearly than the equivalent array literal once you have more than three or four fields.
Trade-offs and Production Considerations
The biggest pitfall with yield-based methods is accidental multiple enumeration. Because the sequence is not materialized, iterating it twice runs the method body twice. If the iterator wraps a database query, an API call, or anything with side effects, you can end up executing that expensive or stateful work more than once without realizing it. If a caller needs to enumerate a result more than once, materialize it explicitly with ToList() right after the call and pass the list around instead of the raw IEnumerable.
Debugging iterator blocks is also less straightforward than debugging a regular method, because the compiler-generated state machine means your breakpoints and stack traces do not map directly onto the code you wrote. Stepping through a yield-based method in the debugger jumps around in a way that can feel unfamiliar at first, since execution pauses at each yield return and resumes only when the next MoveNext() call happens. This is rarely a real problem once you are used to it, but it is worth knowing before you hit it during an incident and wonder why the stack trace looks strange.
Deferred execution can also produce surprising results if the underlying data changes between when the iterator is created and when it is enumerated. If GetSoftwareEngineers() read from a mutable collection instead of a fixed range, and that collection changed before the foreach loop ran, the iterator would reflect the changed state, not the state at the time you called the method. Materializing early with ToList() is the fix whenever you need a stable snapshot.
When Not to Use Yield Return
Skip yield return when the collection is small and bounded, and you need to enumerate it more than once, run LINQ methods like Count() or Contains() repeatedly against it, or pass it across multiple layers of your application. In those cases a plain List<T> is simpler to reason about and avoids the multiple-enumeration trap entirely. Reach for yield return when you are streaming large or unbounded sequences, when the caller may stop early and you want to avoid doing unnecessary work past that point, or when composing lazy pipelines the way LINQ itself does internally.
Leave a Reply