Dataslope logoDataslope

IEnumerable and Iteration

The single most important interface in LINQ, the iterator pattern underneath it, and what 'a sequence' actually means in C#

LINQ is built on a single, deceptively small interface: IEnumerable<T>. Every collection type you'll ever use in C# — List<T>, T[], Dictionary<TKey, TValue>.Values, HashSet<T>, the output of every LINQ operator — is an IEnumerable<T>.

This page is about what that interface actually is, what it promises, and what it deliberately does not promise. Once you understand this, every later page in the course makes more sense.

The interface itself

IEnumerable<T> is two methods:

public interface IEnumerable<T>
{
    IEnumerator<T> GetEnumerator();
}

public interface IEnumerator<T> : IDisposable
{
    T Current { get; }
    bool MoveNext();
    void Reset();   // rarely used in modern code
}

That's it. The contract is:

"You can ask me for an enumerator, which lets you walk through my elements one at a time. Each MoveNext() either advances and returns true, or returns false if we've reached the end."

Notice what's not there:

  • No Count. You may not know how many elements there are.
  • No Indexer. You can't ask for "element 7".
  • No Add or Remove. Sequences may be immutable, infinite, or computed.

This minimal contract is exactly what makes LINQ work uniformly over arrays, files, network streams, and infinite sequences.

foreach is just sugar

A foreach loop in C# is just a sugar over GetEnumerator/ MoveNext/Current. These two programs are equivalent.

Code Block
C# 13

When you call a LINQ operator like Where(...), you receive something that implements IEnumerable<T> — but the elements have not been computed yet. They are computed on demand, one at a time, as MoveNext() is called. This is the foundation of deferred execution, which we'll cover in detail later.

Building your own sequence with yield

The easiest way to produce an IEnumerable<T> is the yield return keyword. The compiler turns your method into a state machine that implements IEnumerator<T> for you.

Code Block
C# 13

Read that carefully. Naturals() would loop forever if you let it. But because IEnumerable<T> is pull-based — values are only produced when the consumer asks — it is safe to define and even safer to use with operators that stop early.

A custom operator written from scratch

To prove how simple the contract is, let's implement our own Where.

Code Block
C# 13

That's the entire implementation. The built-in Enumerable.Where is a touch more sophisticated (it specializes for common types), but the core idea is exactly the eight lines above.

This is the moment LINQ stops looking magical.

What IEnumerable<T> does not promise

This is where most beginner LINQ bugs come from. IEnumerable<T> explicitly does not promise:

What people assumeWhat's actually true
You can call .Count() cheaplyIt may walk the entire sequence
You can iterate twiceIt may be a one-shot sequence (a network stream, a yield method)
Elements are stored in memoryThey may be computed on demand
.Count() and foreach will agreeThey may not, if the source is changing

Practical example of the "iterate twice" trap:

Code Block
C# 13

The source method runs twice. If Source() were reading from a file or making HTTP calls, that's two disk reads or two network trips — usually not what you want.

The fix is to materialize the sequence once, into a real collection:

Code Block
C# 13

Now Source() runs only once. The two passes iterate the in-memory List<int>.

Materializing: ToList, ToArray, ToDictionary

These three operators end a pipeline and return a concrete collection. They are the moment "deferred LINQ" becomes "actual data sitting in memory".

Code Block
C# 13

A useful rule:

Stay in IEnumerable<T> for as long as possible. Materialize exactly once, at the end, when you need to consume the result more than once or want to know the count.

IReadOnlyCollection, ICollection, IList — quick map

It's worth a glance at how the broader hierarchy looks. Most of the time you should type your variables as IEnumerable<T> unless you need more.

  • IEnumerable<T> — can iterate.
  • IReadOnlyCollection<T> — can iterate + has Count.
  • IReadOnlyList<T> — adds indexing.
  • ICollection<T> / IList<T> — add mutation.

If your method only needs to iterate, accept IEnumerable<T>. If you need Count but no mutation, accept IReadOnlyCollection<T>. If you accept List<T> "just in case", you've locked your caller into one concrete type for no reason.

QuestionSelect one

A method has the signature IEnumerable<int> GetTemperatures(). Which assumption about the returned value is safe?

The returned sequence has a known Count property you can read cheaply.

You can iterate the returned sequence multiple times and always get the same results.

You can call foreach over the result and read elements one at a time.

The results are already in memory and indexing is O(1).

On this page