IEnumerable<T> in C#: How foreach Really Works
IEnumerable<T> is the one promise every collection makes: "hand me a way to visit your items, one at a time." foreach is just a polite way of asking for it.
You've now written foreach dozens of times in this module — over arrays, lists, dictionaries, sets, even stacks and queues. It always just works, no matter which collection you point it at. In the previous lesson, you learned why: every one of those types implements IEnumerable<T>. This lesson stops at the surface of that idea and asks: what is IEnumerable<T>, actually, and what does foreach do with it?
This is a gentle first look, not a deep dive — the full mechanics of custom iterators (the yield keyword) and LINQ, which is built entirely on top of IEnumerable<T>, come later in this course. Here, the goal is just a solid, correct mental model of what "enumerable" means and how foreach actually uses it.
What Is It?
The Simple Explanation
Something is enumerable if you can visit each of its items, one after another, in order. IEnumerable<T> is C#'s way of saying "I promise you can do that to me." It doesn't promise a Count. It doesn't promise indexing. It promises exactly one thing: you can walk through my items.
The Technical Definition
IEnumerable<T> is a generic interface with exactly one member:
public interface IEnumerable<T>
{
IEnumerator<T> GetEnumerator();
}That's the entire contract. A type is enumerable simply by promising it can produce an enumerator — a separate, small helper object whose entire job is to remember "where am I in this walk-through, and what's the current item?" IEnumerator<T>, the thing GetEnumerator() returns, has its own small contract:
public interface IEnumerator<T>
{
T Current { get; } // the item at the current position
bool MoveNext(); // advance to the next item; false if there isn't one
void Reset(); // rarely used — jump back to the start
}IEnumerable<T>— the collection's job: "I can hand out an enumerator whenever you ask."IEnumerator<T>— the enumerator's job: "I remember exactly where we are in the walk-through, and I can move forward one step at a time."
Why Does It Exist?
The Problem
Without a shared contract, every collection type would need its own bespoke way of being looped over — an array would need one kind of loop syntax, a dictionary a different one, a custom collection type you write yourself yet another. Code that wants to "just visit every item" would need a separate code path for every single collection type it might ever encounter, including ones that don't exist yet.
The Solution
IEnumerable<T> gives every collection type — built into .NET, or written by you — one shared, standard way to expose "walk through my items." Once a type implements it, it automatically gets to plug into foreach, and (later in this course) the entire LINQ query system, without either of those needing to know anything specific about how that type stores its data internally.
Big Picture
foreach (string name in names)
Console.WriteLine(name);
IEnumerator<string> enumerator = names.GetEnumerator();
try
{
while (enumerator.MoveNext())
{
string name = enumerator.Current;
Console.WriteLine(name);
}
}
finally
{
enumerator.Dispose();
}
foreach is entirely built on GetEnumerator(), MoveNext(), and Current — nothing more.
How It Works
foreachcallsGetEnumerator()exactly once, at the start, getting back a fresh enumerator positioned before the first item.
foreachcallsMoveNext(). If there's another item, the enumerator advances andMoveNext()returnstrue. If there's nothing left, it returnsfalseand the loop ends.
- If
MoveNext()returnedtrue,foreachreadsCurrentto get the item at the enumerator's new position, and assigns it to your loop variable (name, in the example above).
- Steps 2 and 3 repeat for every item. Once
MoveNext()finally returnsfalse, the loop exits automatically — you never write this bookkeeping yourself.
Simple Example
Let's build a tiny custom collection from scratch, and make it work with foreach by implementing IEnumerable<T> ourselves — no shortcuts, so you can see exactly how the pieces fit together.
public class NameList : IEnumerable<string>
{
private readonly string[] _names;
public NameList(params string[] names) => _names = names;
// The one member IEnumerable<string> requires:
public IEnumerator<string> GetEnumerator() => new NameEnumerator(_names);
// Required by the non-generic IEnumerable that IEnumerable<T> extends —
// just forwards to the generic version above.
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
=> GetEnumerator();
// A small helper class that does the actual step-by-step walking.
private class NameEnumerator : IEnumerator<string>
{
private readonly string[] _names;
private int _position = -1; // starts BEFORE the first item
public NameEnumerator(string[] names) => _names = names;
public string Current => _names[_position];
object System.Collections.IEnumerator.Current => Current;
public bool MoveNext()
{
_position++;
return _position < _names.Length;
}
public void Reset() => _position = -1;
public void Dispose() { } // nothing to clean up here
}
}
var names = new NameList("Ana", "Ben", "Cara");
foreach (string name in names)
Console.WriteLine(name);
// Ana
// Ben
// CaraCode → Meaning → Result:
NameListdoesn't do the walking itself — it just knows how to produce a freshNameEnumeratorwhenever asked, viaGetEnumerator().NameEnumeratoris the one that actually remembers position: it starts at-1(before the first item), and eachMoveNext()call steps forward by one, reportingfalseonce it runs off the end.- The moment
NameListimplementsIEnumerable<string>, it can be used in aforeachexactly like an array or aList<T>— no special casing anywhere in the language.
Real-World Example
A team builds a small WeeklySchedule class for a shift-planning app — it wraps a fixed 7-day array internally, but the team wants callers to be able to simply foreach over "today's shifts for the week" without knowing or caring that an array is involved underneath.
public record Shift(DayOfWeek Day, string EmployeeName);
public class WeeklySchedule : IEnumerable<Shift>
{
private readonly List<Shift> _shifts = [];
public void AddShift(Shift shift) => _shifts.Add(shift);
// Because List<T> is already enumerable, we can simply hand back its enumerator —
// there's no need to hand-write one ourselves every time.
public IEnumerator<Shift> GetEnumerator() => _shifts.GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
=> GetEnumerator();
}
var schedule = new WeeklySchedule();
schedule.AddShift(new Shift(DayOfWeek.Monday, "Amy"));
schedule.AddShift(new Shift(DayOfWeek.Tuesday, "Ben"));
schedule.AddShift(new Shift(DayOfWeek.Wednesday, "Cara"));
// The caller has no idea a List<Shift> is hiding underneath — and doesn't need to.
foreach (Shift shift in schedule)
Console.WriteLine($"{shift.Day}: {shift.EmployeeName}");
// Monday: Amy
// Tuesday: Ben
// Wednesday: CaraNotice this version doesn't need a hand-written enumerator class at all — since _shifts is a List<Shift>, and List<T> is already enumerable, WeeklySchedule can simply delegate to it. This is by far the most common way real code implements IEnumerable<T>: wrap an already-enumerable collection internally, and forward the request.
Analogy
Think of a music playlist (the collection) and a physical bookmark you place in it (the enumerator). The playlist itself doesn't "remember" where you currently are — it just holds the songs. The bookmark is a separate little object whose only job is tracking your current position: "move to the next song" (MoveNext) and "tell me which song we're on right now" (Current).
You could hand two different friends two separate bookmarks for the same playlist, and each could be at a completely different song, independently — which is exactly why GetEnumerator() hands back a fresh enumerator each time it's called: every "walk-through" of the collection gets its own independent bookmark, so multiple simultaneous loops over the same collection never interfere with each other.
Under the Hood
- As shown in "Big Picture" above,
foreachisn't magic built directly into the runtime for each collection type — the compiler simply rewrites it into calls toGetEnumerator(),MoveNext(), andCurrent. Any type offering those (viaIEnumerable<T>) automatically works.
- If the collection itself tracked "current position," you could never have two
foreachloops over the same list running at once — they'd fight over one shared position. By making the enumerator its own small object, each call toGetEnumerator()hands out an independent bookmark, so nested or parallel loops over the same collection work safely.
- Every LINQ method you'll meet later in this course (
Where,Select,OrderBy, and dozens more) works by consuming anIEnumerable<T>and producing another one — which is exactly why LINQ works identically across arrays, lists, dictionaries, and any custom type you make enumerable, including theNameListyou just built.
- Hand-writing an enumerator class, as shown above, is real but rarely necessary in practice — C#'s
yield returnkeyword lets you write an enumerator's logic as ordinary-looking code, and the compiler generates the enumerator class for you behind the scenes. That mechanism, along with the full depth of LINQ, is covered later in this course, once you have more collection experience to build on.
Common Confusion
1. IEnumerable<T> vs IEnumerator<T> — easy to mix up by name
They sound almost identical, but they answer different questions. IEnumerable<T> asks "can I be walked through?" — a collection implements this. IEnumerator<T> asks "where are we right now in that walk-through?" — the enumerator returned by GetEnumerator() implements this. You almost never implement IEnumerator<T> directly in everyday code (as the real-world example showed, delegating to an already-enumerable field is far more common) — but understanding what it does makes foreach far less mysterious.
2. "Enumerable" doesn't mean "has a Count" or "can be indexed"
As you learned in the Collection Interfaces lesson, IEnumerable<T> is the smallest, most basic contract in the whole hierarchy. Being enumerable only promises sequential access — nothing about size, position, or mutability. Don't assume every IEnumerable<T> you receive has a Count or supports indexing; if you need those, the parameter type should say so explicitly (ICollection<T> or IList<T>).
Common Mistakes
Mistake 1 — Assuming every IEnumerable<T> has a Length or Count
Wrong — this won't compile; IEnumerable<T> makes no such promise:
void PrintCount(IEnumerable<string> items)
{
Console.WriteLine(items.Count); // IEnumerable<T> has no Count member
}Correct — if a count is genuinely needed, require a more specific interface:
void PrintCount(ICollection<string> items)
{
Console.WriteLine(items.Count); // ICollection<T> guarantees Count
}Mistake 2 — Building a custom enumerator that shares one position field on the collection itself
Storing "current position" directly on the collection class (instead of a separate enumerator, as shown in the Simple Example above) means two simultaneous foreach loops over the same instance would corrupt each other's progress. Always give each walk-through its own independent enumerator object, exactly as GetEnumerator() is meant to do.
Mistake 3 — Modifying a collection while a foreach loop is enumerating it
You saw this exact mistake in the List<T> lesson — it throws InvalidOperationException. Now you know why: most built-in enumerators detect a change to the underlying collection mid-walk-through and deliberately fail loudly, rather than silently producing incorrect or skipped results. Collect items to remove first, or loop by index backwards, as shown in that earlier lesson.
When Should I Use It?
Implement IEnumerable<T> yourself when
- You're building a custom type that logically is a collection of something, and you want it to work naturally with
foreach(and later, LINQ). - You want to hide an internal storage detail (like the
WeeklyScheduleexample) while still exposing a clean, iterable surface.
You usually don't need to implement it when
- You're just using existing collections (
List<T>,Dictionary<TKey,TValue>, etc.) — they already implement it for you. - A property can simply expose one of those existing collections directly (or as
IReadOnlyList<T>) instead of wrapping a whole new custom type.
IEnumerable<T> (by writing methods that accept it, as in the previous lesson) far more often than it implements it from scratch. Recognizing what it promises, and how foreach uses it, matters more at this stage than being able to hand-write enumerators fluently — that fluency comes naturally once yield return and LINQ enter the picture later in the course.
Mental Model
GetEnumerator() = "here's a fresh bookmark, starting before the first item"
IEnumerator<T> = the bookmark itself —
MoveNext() to advance, Current to readRemember:
·
foreach is just repeated calls to MoveNext() and Current — nothing more mysterious than that.· The enumerator is a separate object from the collection, so multiple walk-throughs never interfere with each other.
· Every collection type in this module implements
IEnumerable<T> — that's the thread tying this entire module together.
Key Takeaway
- IEnumerable<T> promises exactly one thing: the ability to hand out an
IEnumerator<T>viaGetEnumerator(). foreachis compiler sugar — it's mechanically rewritten into calls toGetEnumerator(), then repeatedMoveNext()/CurrentuntilMoveNext()returnsfalse.- The enumerator is a separate, independent object from the collection — so multiple simultaneous walk-throughs never step on each other.
- Implementing
IEnumerable<T>on a custom type usually just means delegating to an already-enumerable field, as shown in the real-world example. - This lesson is a first look on purpose —
yield returnand LINQ, both built directly on top of what you learned here, are covered in depth later in the course.
Check Your Understanding
You've taken a first look at how IEnumerable<T> and foreach connect. Let's check your understanding.
1. What is the single member that the IEnumerable<T> interface requires a type to implement?
Show answer
Correct: B
Why B is correct: IEnumerable<T>'s entire contract is a single method, GetEnumerator(), which returns an IEnumerator<T> for walking through the items.
Why A is incorrect: Count belongs to ICollection<T>, a more specific interface built on top of IEnumerable<T> — not to IEnumerable<T> itself.
Why C is incorrect: MoveNext() belongs to IEnumerator<T> — the object GetEnumerator() returns, not IEnumerable<T> itself.
Why D is incorrect: Indexing belongs to IList<T>, a much more specific interface — IEnumerable<T> makes no promise about positional access at all.
Reinforcement: IEnumerable<T> is deliberately minimal — one method, which is exactly what lets so many different collection shapes implement it.
2. Roughly, what does a foreach loop get translated into by the compiler?
Show answer
Correct: B
Why B is correct: As shown in "Big Picture," the compiler mechanically rewrites foreach into exactly this pattern — get an enumerator, then loop on MoveNext()/Current until there's nothing left.
Why A is incorrect: No copying happens — foreach reads items one at a time through the enumerator, directly from the original collection.
Why C is incorrect: foreach never sorts anything, and doesn't rely on indexed access at all — many enumerable types (like HashSet<T>) have no indexer.
Why D is incorrect: foreach's behavior is entirely defined in terms of the IEnumerable<T>/IEnumerator<T> contract — that relationship is exactly what this lesson covered.
Reinforcement: Once you know this translation, foreach stops being "magic" and becomes a predictable pattern you could write by hand.
3. Why does GetEnumerator() return a brand-new enumerator object each time it's called, rather than the collection tracking its own single "current position"?
Show answer
Correct: B
Why B is correct: If "current position" lived on the collection itself, two loops over the same collection at the same time would corrupt each other's progress. A fresh enumerator per call means each walk-through gets its own independent bookmark.
Why A is incorrect: The two approaches behave very differently the moment more than one enumeration happens concurrently — this is precisely the scenario the separate-enumerator design protects against.
Why C is incorrect: Collections routinely store fields (like a backing array) — that's unrelated to why enumerators are separate objects.
Why D is incorrect: This design is about safe concurrent iteration, not about preventing changes to the data — many enumerable collections are still fully mutable.
Reinforcement: Separating "the data" from "where we currently are while walking through it" is what makes independent, simultaneous enumeration possible.
4. A custom ProductCatalog class internally stores its products in a List<Product> field. What is the simplest correct way to make ProductCatalog work with foreach?
Show answer
Correct: B
Why B is correct: As shown in the real-world WeeklySchedule example, since the internal List<Product> is already enumerable, ProductCatalog can implement IEnumerable<Product> and simply delegate: GetEnumerator() => _products.GetEnumerator();. No custom enumerator class is needed.
Why A is incorrect: This would work, but it's unnecessary extra code — delegating to the already-enumerable field accomplishes the exact same result far more simply.
Why C is incorrect: Any type — built-in or custom — can be used with foreach as soon as it implements IEnumerable<T>, exactly as this lesson demonstrated with NameList.
Why D is incorrect: A Count property alone satisfies no enumeration contract at all — foreach specifically needs GetEnumerator(), which IEnumerable<T> provides.
Reinforcement: Delegating to an already-enumerable internal field is the most common, simplest way real-world code implements IEnumerable<T>.
You've completed Part IV — Collections and Data. You now understand arrays, multidimensional and jagged arrays, List<T>, Dictionary<TKey,TValue>, HashSet<T>, Stack<T> and Queue<T>, the interface hierarchy that ties them together, and the first-look mechanics of IEnumerable<T> that make foreach — and eventually LINQ — possible.