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.
IEnumerable<T> is not a collection. It is an interface — a promise — and the promise is tiny. Stripped down, this is the whole thing:
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.
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:
// 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.
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 the promise being used. This loop:
foreach (var item in items)
Handle(item);is shorthand for "ask for a cursor, then keep advancing it until the items run out":
var cursor = items.GetEnumerator();
while (cursor.MoveNext()) // advance; false when nothing is left
Handle(cursor.Current); // read where the cursor now sitsSo 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.
The other interfaces are just IEnumerable<T> plus more promises. Each step up adds a capability:
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.
Alongside the mutable interfaces sits a parallel set of read-only ones. They promise reading but not changing:
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:
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:
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 changeFor 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.
Dictionaries are a separate branch, because their items are key/value pairs. Same idea, though — a mutable interface and a read-only twin:
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.
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:
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.
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:
| Type | Main interface | Structure | Find an item | Add an item | Order kept |
|---|---|---|---|---|---|
List<T> | IList<T> | array | O(n) by value | O(1) at end | insertion |
T[] (array) | IList<T> (fixed size) | array | O(n) by value | fixed size | insertion |
HashSet<T> | ISet<T> | hash table | O(1) | O(1) | none |
SortedSet<T> | ISet<T> | tree | O(log n) | O(log n) | sorted |
Dictionary<K,V> | IDictionary<K,V> | hash table | O(1) by key | O(1) | none |
SortedDictionary<K,V> | IDictionary<K,V> | tree | O(log n) by key | O(log n) | sorted by key |
SortedList<K,V> | IDictionary<K,V> | arrays | O(log n) by key | O(n) | sorted by key |
Queue<T> | ICollection | ring buffer | O(n) | O(1) enqueue | FIFO |
Stack<T> | ICollection | array | O(n) | O(1) push | LIFO |
LinkedList<T> | ICollection<T> | linked nodes | O(n) | O(1) at the ends | insertion |
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.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.
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:
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:
IEnumerable<int> Naturals()
{
int i = 0;
while (true) yield return i++; // an infinite sequence
}
var firstTen = Naturals().Take(10).ToList(); // pulls exactly 10, then stopsA 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 is how a method becomes a sequence with no backing collection. The compiler turns it into the cursor machinery automatically:
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.
Because a lazy sequence re-runs its recipe every time it is walked, touching one twice does the work twice:
IEnumerable<int> q = source.Where(Expensive); // deferred
int count = q.Count(); // runs Expensive over everything
int first = q.First(); // runs Expensive AGAIN from the startIf 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:
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"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."
Two practical rules tie the whole family back to everyday code:
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.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.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.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.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.Immutable... type.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.