MOFAKH.COM
← Back to profile
Miscellaneous

C# IEnumerable<T>: the one contract everything iterable agrees to

Aug 25, 202617 min readWritten

IEnumerable<T> is not a collection. It is a promise with a single method behind it — hand over elements one at a time — and that promise is what foreach, LINQ, yield, and lazy streaming are all built on. This note takes it apart from the interface down.

The .NET collection interfaces look like alphabet soup at first: IEnumerable, ICollection, IList, ISet, IReadOnlyCollection, IReadOnlyList, IReadOnlyDictionary. It is easy to feel lost and to try to memorize the whole tree. The good news: there is a simple reason all of them exist, and a way to keep them straight by asking one question at a time. That is what this note is really about.

Everything starts from the smallest one, IEnumerable<T>, so it starts there too.

The one promise: "you can walk my items"

IEnumerable<T> is not a collection. It is an interface — a promise — and the promise is tiny. Stripped down, this is the whole thing:

csharp
public interface IEnumerable<T>
{
    IEnumerator<T> GetEnumerator();   // "here is a cursor to walk my items"
}

One method. It does not promise a count, an index, an Add, or the ability to look at item five without walking past one through four first. It promises exactly one capability: the items can be walked through, one at a time, from start to finish.

Picture a deli counter. A number gets called, that customer is served, the next number is called. No skipping ahead, no jumping to a specific customer — just forward, in order. That is all IEnumerable<T> guarantees.

Almost everything holds items and can honour that promise, so almost everything implements it: List<T>, arrays, HashSet<T>, dictionaries, and sequences generated on the fly.

Why bother with an interface at all?

This is the question that clears up the confusion, so it comes before the details. Why not just use List<T> everywhere?

Because a method that only needs to walk items should not demand a List<T>. If it does, an array or a HashSet<T> cannot be passed in, even though both can be walked perfectly well. Asking for the smallest promise — IEnumerable<T> — means every kind of collection fits.

LINQ is the clearest proof. Where, Select, OrderBy, and the rest are not methods on List<T>. They are extension methods written once against IEnumerable<T>, living in a class called System.Linq.Enumerable:

csharp
// Roughly how LINQ defines Where — note the type it extends:
public static IEnumerable<T> Where<T>(
    this IEnumerable<T> source,           // works on ANYTHING enumerable
    Func<T, bool> predicate) { /* ... */ }

So myList.Where(...) compiles even though List<T> has no Where method of its own — the extension method takes over because a list is an IEnumerable<T>. And the exact same Where works on an array, a set, or a generated sequence, because all of them are too.

Note to self

The one idea to hold onto: depend on the smallest promise that does the job. A method that only loops should accept IEnumerable<T>, not List<T> — then every caller, with every kind of collection, can use it. Every interface below IEnumerable<T> exists to express a bigger promise, for the times when looping is not enough. That is the whole reason there are so many.

foreach is that promise in action

foreach is the promise being used. This loop:

csharp
foreach (var item in items)
    Handle(item);

is shorthand for "ask for a cursor, then keep advancing it until the items run out":

csharp
var cursor = items.GetEnumerator();
while (cursor.MoveNext())     // advance; false when nothing is left
    Handle(cursor.Current);   // read where the cursor now sits

So the single method GetEnumerator is enough to make any type loopable. That is why implementing IEnumerable<T> is all it takes for a custom type to work in a foreach and with LINQ.

Climbing the family: from "iterate" to "do everything"

The other interfaces are just IEnumerable<T> plus more promises. Each step up adds a capability:

Diagram
IEnumerable<T>                 walk the items, one at a time
|
+- ICollection<T>             + Count, Add, Remove, Clear, Contains
|  |
|  +- IList<T>                + list[index], Insert, RemoveAt   (ordered, indexable)
|  +- ISet<T>                 + UnionWith, IntersectWith, ...   (unique items)

Reading it as capabilities:

  • ICollection<T> adds the ability to change the collection and know its size: Count, Add, Remove, Clear, Contains. This is the "I can mutate it" level.
  • IList<T> adds position: list[index], Insert, RemoveAt, IndexOf. Use it when order and getting-by-position matter. List<T> and arrays live here.
  • ISet<T> adds uniqueness and set math: UnionWith, IntersectWith, IsSubsetOf. It keeps items unique and supports set operations. HashSet<T> and SortedSet<T> live here.

Each is a superset of the one above. An IList<T> can do everything an ICollection<T> can, which can do everything an IEnumerable<T> can.

The read-only twins

Alongside the mutable interfaces sits a parallel set of read-only ones. They promise reading but not changing:

Diagram
IEnumerable<T>
|
+- IReadOnlyCollection<T>     + Count                (read-only)
   |
   +- IReadOnlyList<T>        + list[index]          (read-only, indexable)
   +- IReadOnlySet<T>         + Contains, IsSubsetOf  (read-only, set)

An IReadOnlyCollection<T> lets code count and enumerate, but exposes no Add, Remove, or Clear. It is useful for handing a collection to code that should look but not touch.

There is one trap here that catches everyone, so it gets its own box:

Note to self

Read-only view is not the same as immutable. IReadOnlyList<T> means "this reference cannot change the collection" — not "the collection cannot change." The underlying object might still be a mutable List<T> that someone else edits:

csharp
var list = new List<int> { 1, 2, 3 };
IReadOnlyList<int> view = list;   // a read-only VIEW of the same list
 
// view.Add(4);   // does not exist — the view cannot change it
list.Add(4);      // but the original reference still can
 
Console.WriteLine(view.Count);    // 4 — the view saw the change

For a collection that genuinely cannot change no matter who holds it, that is a different thing: the Immutable... types in System.Collections.Immutable, which return a new collection instead of changing the old one.

Key to value: dictionaries

Dictionaries are a separate branch, because their items are key/value pairs. Same idea, though — a mutable interface and a read-only twin:

Diagram
IEnumerable<KeyValuePair<K,V>>
|
+- IDictionary<K,V>            + dict[key], Keys, Values, TryGetValue, Add, Remove
|
+- IReadOnlyDictionary<K,V>    + dict[key], Keys, Values, TryGetValue   (read-only)

IDictionary<K,V> is "look things up by a key, and change them." IReadOnlyDictionary<K,V> is the look-but-do-not-touch version. Dictionary<K,V> implements both, so the same object can be handed out as either depending on what the receiver is allowed to do.

The model to actually remember

Here is the part that replaces memorizing the tree. Do not start from the interfaces — start from what the code needs to do, and let the answer pick the interface:

Diagram
What does the code need to do with the items?
 
"Just walk through them"
    -> IEnumerable<T>
 
"Also count them, add, or remove"
    -> ICollection<T>
        "...and get one by position, like list[3]"   -> IList<T>
        "...and keep them unique / do set math"       -> ISet<T>
 
"Only read them, never change them"  (a read-only view)
    -> IReadOnlyCollection<T>
        "...by position"      -> IReadOnlyList<T>
        "...as a unique set"  -> IReadOnlySet<T>
 
"Look things up by a key"
    -> IDictionary<K,V>           (read and write)
    -> IReadOnlyDictionary<K,V>   (read-only view)

Two questions cover almost everything: can I only read, or also change it? and how do I reach an item — walk to it, by position, by key, or is it a set? Answer those and the interface falls out. There is nothing to memorize beyond the questions.

Picking a concrete type

The interfaces say what a collection can do; the concrete types decide how fast and in what order, based on their internal structure. This is the reference to skim, not memorize — the shape of it is what matters:

TypeMain interfaceStructureFind an itemAdd an itemOrder kept
List<T>IList<T>arrayO(n) by valueO(1) at endinsertion
T[] (array)IList<T> (fixed size)arrayO(n) by valuefixed sizeinsertion
HashSet<T>ISet<T>hash tableO(1)O(1)none
SortedSet<T>ISet<T>treeO(log n)O(log n)sorted
Dictionary<K,V>IDictionary<K,V>hash tableO(1) by keyO(1)none
SortedDictionary<K,V>IDictionary<K,V>treeO(log n) by keyO(log n)sorted by key
SortedList<K,V>IDictionary<K,V>arraysO(log n) by keyO(n)sorted by key
Queue<T>ICollectionring bufferO(n)O(1) enqueueFIFO
Stack<T>ICollectionarrayO(n)O(1) pushLIFO
LinkedList<T>ICollection<T>linked nodesO(n)O(1) at the endsinsertion

A few takeaways worth more than the exact numbers:

  • List<T> finds by value slowly (O(n)) but reaches by position instantly (O(1)) via list[i]. It is the sensible default for an ordered, growable collection.
  • HashSet<T> and Dictionary<K,V> are the fast-lookup types — checking membership or fetching by key is roughly O(1) — but they keep no order.
  • The Sorted... types keep things in order at the cost of O(log n) operations, using a tree (or, for SortedList, arrays that trade slower inserts for lower memory).
  • Queue<T> and Stack<T> are for FIFO and LIFO access and deliberately do not offer indexing — they are ICollection, not IList.
  • LinkedList<T> is fast to add or remove at the ends or at a known node, but slow to search or index — it is not an IList<T>.

The habit the conversation this note grew from suggested is a good one: for each type, know its structure and therefore its costs. That, plus the capabilities model above, is the whole collection system.

The special power: lazy sequences

One thing sets IEnumerable<T> apart from the collections below it and is worth understanding early, because it explains a lot of surprising behaviour: an IEnumerable<T> does not have to hold its items. It can produce them on demand.

Most LINQ operators build a description of work and run nothing until something actually walks the sequence:

csharp
IEnumerable<int> query = numbers.Where(x => x > 2);   // nothing has run yet
 
foreach (var n in query)   // ONLY NOW does Where actually filter
    Console.WriteLine(n);

This is called deferred execution, and it is why an IEnumerable<T> can even be endless — it never has to store everything:

csharp
IEnumerable<int> Naturals()
{
    int i = 0;
    while (true) yield return i++;   // an infinite sequence
}
 
var firstTen = Naturals().Take(10).ToList();   // pulls exactly 10, then stops

A List<T> is finished data sitting in memory; an IEnumerable<T> can be a recipe that produces data as it is asked for.

yield return: a sequence without a collection

yield return is how a method becomes a sequence with no backing collection. The compiler turns it into the cursor machinery automatically:

csharp
public IEnumerable<int> EvensUpTo(int max)
{
    for (int i = 0; i <= max; i += 2)
        yield return i;    // hand out i, pause here, resume on the next step
}

Each step of a foreach runs the method until the next yield return, hands back that value, and freezes until asked again. No list is ever built.

The one trap everyone hits: enumerating twice

Because a lazy sequence re-runs its recipe every time it is walked, touching one twice does the work twice:

csharp
IEnumerable<int> q = source.Where(Expensive);   // deferred
 
int count = q.Count();     // runs Expensive over everything
int first = q.First();     // runs Expensive AGAIN from the start

If the source can only be read once (a file, a network stream), the second pass may even fail. The fix is to run it once and keep the result:

csharp
List<int> results = source.Where(Expensive).ToList();   // run it a single time
int count = results.Count;   // now free
int first = results[0];      // no re-run
Note to self

"Possible multiple enumeration" is the exact warning an analyzer gives for this. Rule of thumb: the moment an IEnumerable<T> needs to be read more than once — or read and also counted — call .ToList() (or .ToArray()) first to freeze it into a real collection. Returning IEnumerable<T> from a method quietly hands this hazard to the caller; returning IReadOnlyList<T> signals "already a real, safe-to-reread collection."

Choosing the type in your own methods

Two practical rules tie the whole family back to everyday code:

  • For a parameter, ask for the weakest interface the method actually uses. Only looping? IEnumerable<T>. Need a count? IReadOnlyCollection<T>. Need indexing? IReadOnlyList<T>. Demanding List<T> when only looping shuts out arrays and every other collection for no reason.
  • For a return type, let the type signal intent. IEnumerable<T> says "a sequence, possibly lazy, walk it once." IReadOnlyList<T> says "a finished collection, safe to index and re-read." Choosing deliberately saves the caller from the multiple-enumeration trap.

Going a little deeper (optional)

Three things worth knowing eventually, but safe to skip on a first pass:

  • IQueryable<T> vs IEnumerable<T>. IEnumerable<T> runs LINQ in memory, in C#. IQueryable<T> (what EF Core returns) instead translates the LINQ into SQL and runs it in the database. Same-looking Where/Select, very different place of execution — which is why the distinction matters the moment a database is involved.
  • Non-generic IEnumerable. The old, pre-generics version, whose items are object. It only survives for backward compatibility; new code uses IEnumerable<T>.
  • IAsyncEnumerable<T>. The streaming version for data that arrives over time (network, disk), consumed with await foreach. Same "one item at a time, lazily" idea, made asynchronous.

What to take away

  • IEnumerable<T> is one promise — "the items can be walked, one at a time" — and it is the base every other collection interface builds on.
  • The reason for the whole family is depend on the smallest promise that does the job; each interface above adds a capability (count/mutate, index, set math, key lookup).
  • Remember them by asking questions, not memorizing a tree: read-only or mutable? reach items by walking, by position, by key, or as a set?
  • Read-only interface is not immutable — it is a view; the real thing can still change unless it is an Immutable... type.
  • Concrete types differ by internal structure and cost: List for ordered/indexed, HashSet/Dictionary for fast lookup, Sorted... for order, Queue/Stack for FIFO/LIFO.
  • IEnumerable<T> is special for being lazy — deferred execution and yield — with the one catch that it re-runs on every enumeration, so freeze it with ToList() when reading more than once.