Every foreach loop and every LINQ query works through one small interface: IEnumerable<T>. Understanding it explains why LINQ is lazy, why some sequences can only be read once, and how yield return lets you write your own sequences with a plain loop.
What IEnumerable is
IEnumerable<T> has a single method, GetEnumerator(). The enumerator it returns has MoveNext() (advance, and report whether there is an element) and Current (the element). A foreach is shorthand for driving that enumerator:
Output:
red green blue
red green blue
The enumerator starts before the first element, so the first MoveNext() moves onto it. When MoveNext() returns false the sequence is over. The using block disposes the enumerator, which matters for iterators that hold resources such as open files.
IEnumerable vs ICollection vs List
The collection interfaces form a ladder. Each step promises more:
| Type | Adds | Typical use |
|---|---|---|
IEnumerable<T> | foreach only | Parameters you only loop over; LINQ results; lazy sequences |
IReadOnlyCollection<T> | Count | Return values the caller should not modify |
ICollection<T> | Count, Add, Remove, Contains | Code that adds to a collection of any kind |
IReadOnlyList<T> | Index access [i] | Read-only results with positions |
IList<T> | Index access, Insert, RemoveAt | Code that edits by position |
List<T> | The concrete class implementing all of these | Storage |
A method that only loops over its input should accept IEnumerable<T>, so callers can pass an array, a list, a set or a LINQ query without converting. A method that returns data should usually return something that says "this is already in memory", such as List<T> or IReadOnlyList<T>, because an IEnumerable<T> return value might be a query that does work every time it is read.
Output:
30
15
4.0
30
Writing an iterator with yield return
To produce your own sequence, write a method that returns IEnumerable<T> and use yield return for each element. The compiler turns the method into a class that implements the enumerator for you.
Output:
0, 2, 4, 6, 8, 10
Mon Tue Wed Thu Fri
There is no list anywhere in EvenNumbers. Each yield return hands one value to the caller and pauses the method, keeping its local variables (i here) alive until the caller asks for the next value.
Laziness: watch the execution order
Because the method pauses, its body and the caller's loop take turns. Printing from both sides shows the interleaving:
Output:
Called Numbers(), nothing printed yet
start
yielding 1
Loop got 1
resumed after 1
yielding 2
Loop got 2
resumed after 2
yielding 3
Loop got 3
resumed after 3
end
Calling Numbers() did not print "start": it only created the enumerable. The body began on the first MoveNext() inside the foreach, ran until the first yield return, and stopped. Each later step resumed on the line after the yield. LINQ's Where and Select behave the same way (the runtime implements them with hand-written enumerator classes for speed, but the effect is identical), which is why LINQ queries are deferred.
yield break
yield break ends the sequence early, the iterator version of return:
Output:
Subject: Invoice
From: shop@example.com
You cannot mix return value; and yield return in one method. Once a method contains yield, it is an iterator, and yield break is the only way to leave it early.
Infinite sequences
Since only the requested elements are ever computed, an iterator can describe a sequence with no end. The consumer decides how much to take:
Output:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34
1597
2026-09-28 Mon
2026-10-05 Mon
2026-10-12 Mon
while (true) is safe here because Take and First stop asking after they have what they need. Calling ToList() or Count() on an infinite sequence, on the other hand, never returns, and neither does enumerating an OrderBy over it, because sorting needs every element first.
Pitfalls of lazy sequences
Argument checks run late. The whole body of an iterator is deferred, including validation at the top. The fix is to split the method: a normal method checks the arguments and returns the result of a private iterator.
Output:
LazyRange(-1) returned without an error
...and threw only when enumerated
EagerRange(-1) threw at the call
Every enumeration runs the method again. An iterator does not cache anything. Looping twice over Numbers() prints "start" twice, re-reads the file twice, or calls the web service twice. IDEs flag this as "possible multiple enumeration of IEnumerable". Call ToList() once when you need the elements more than once.
finally runs when the consumer stops. If the caller breaks out of the foreach early, the enumerator is disposed and any finally block in the iterator runs, which is how an iterator reading a file closes it:
Output:
open sensor
10
20
30
close sensor
done
A few rules the compiler enforces on iterators: yield return cannot appear inside a try block that has a catch (a try/finally is fine), inside a lambda or anonymous method, or in a method with ref or out parameters.
Async streams
For sequences whose elements arrive asynchronously (pages from an API, rows from a database), C# 8 and later (with .NET Core 3.0 or later) combine iterators with await through IAsyncEnumerable<T>:
static async IAsyncEnumerable<string> FetchPages()
{
for (int page = 1; page <= 3; page++)
{
await Task.Delay(100); // simulate a network call
yield return $"page {page}";
}
}
await foreach (string p in FetchPages())
{
Console.WriteLine(p);
}
The consumer uses await foreach, and each element is awaited as it is produced.
Common mistakes
- Returning
IEnumerable<T>from a query and reading it many times. Each read reruns it; return aList<T>instead. - Validating arguments in an iterator. The check is deferred; split into a wrapper and an iterator.
- Calling
Count()orToList()on an infinite iterator. It never ends;Takefirst. - Expecting an
IEnumerable<T>parameter to haveCountor[i]. Use LINQ'sCount()(which may enumerate) or acceptIReadOnlyList<T>.
Frequently Asked Questions
What is IEnumerable in C#?
IEnumerable<T> is the interface for anything that can be looped over with foreach. It has one method, GetEnumerator(), which returns an IEnumerator<T> with MoveNext() and Current. Arrays, lists, dictionaries, sets, LINQ queries and iterator methods all implement it.
What is the difference between IEnumerable and List in C#?
IEnumerable<T> only promises that you can iterate; it has no Count property, no indexer and no Add, and it may be computed lazily each time you loop. List<T> is a concrete collection stored in memory with all of those. Accept IEnumerable<T> in parameters when you only loop, and return a List<T> or IReadOnlyList<T> when callers need to index or count.
What does yield return do in C#?
yield return value; inside a method that returns IEnumerable<T> hands one element to the caller and pauses the method at that point. The next time the caller asks for an element, the method resumes on the next line. The compiler rewrites the method into a state machine, so you write a loop and get a lazy sequence.
What is yield break in C#?
yield break; ends the sequence: the caller's foreach finishes normally after the elements already produced. It is the iterator equivalent of return and is used to stop early, for example when a limit is reached or the input is empty.
Why does my iterator method not throw an exception when I call it?
The body of an iterator method does not run until the sequence is enumerated, so argument checks at its top run on the first MoveNext, not at the call. To validate eagerly, put the checks in a normal public method that then calls a private iterator method holding the yield statements.