Back to Blog
C#

Understanding IEnumerator in C#

c# ienumerator: Learn how IEnumerator powers foreach in C#, how to implement custom enumerators manually or with yield, and the performance tradeoffs to consider.

IEnumeratorIEnumerableforeachyield returnCustom IteratorsC# Collections
A diagram showing a foreach loop connecting to an IEnumerator interface with MoveNext and Current, symbolizing iteration in C#.

When you write a foreach loop in C#, the compiler translates it into calls on an IEnumerator<T> instance. Understanding c# ienumerator directly helps you reason about iteration behavior, build custom collections, and avoid subtle performance pitfalls. This article explains the interface, shows both manual and compiler-generated implementations, and covers the practical decisions you face when designing iterators.

What IEnumerator Is and Why It Matters

IEnumerator is the interface that defines the low-level iteration contract in .NET. The non-generic version exists in System.Collections, and the generic version IEnumerator<T> lives in System.Collections.Generic. Every foreach loop relies on an enumerator to move through a sequence, even if you never touch the interface yourself.

A type that implements IEnumerator provides three members: Current, MoveNext(), and Reset(). The generic version also implements IDisposable. The runtime calls MoveNext() before each access to Current, and the loop ends when MoveNext() returns false. This simple contract is what makes foreach work across arrays, lists, dictionaries, and your own custom types.

The IEnumerator Interface Members

The non-generic IEnumerator interface looks like this:

public interface IEnumerator { object Current { get; } bool MoveNext(); void Reset(); }

The generic version adds type safety and disposal:

public interface IEnumerator<out T> : IDisposable, IEnumerator { new T Current { get; } }

MoveNext() advances the enumerator to the next element. The first call moves to the first element; subsequent calls move forward. When there are no more elements, it returns false. Current returns the element at the current position. Its behavior is undefined before the first MoveNext() or after the last one returns false. Reset() is intended to return the enumerator to its initial state, but many implementations throw NotSupportedException because the underlying data source cannot be rewound. Dispose() releases any resources held by the enumerator, such as an open file or a database connection.

Implementing a Custom Enumerator

Suppose you have a simple collection that you want to make iterable without exposing the underlying storage. You can implement IEnumerator<T> manually. Here is a minimal example for a custom list wrapper:

public class MyList<T> : IEnumerable<T> { private readonly T[] _items; public MyList(T[] items) { _items = items; } public IEnumerator<T> GetEnumerator() { return new MyEnumerator(_items); } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); private class MyEnumerator : IEnumerator<T> { private readonly T[] _items; private int _index = -1; public MyEnumerator(T[] items) { _items = items; } public T Current => _items[_index]; object IEnumerator.Current => Current; public bool MoveNext() { _index++; return _index < _items.Length; } public void Reset() { _index = -1; } public void Dispose() { // No resources to release in this example. } } }

This implementation keeps the iteration state in a private class. The _index field starts at -1 so the first MoveNext() moves to index 0. The Current property returns the element at the current index. The explicit interface implementation for IEnumerator.Current satisfies the non-generic contract while keeping the public Current strongly typed.

Manual implementations are useful when you need precise control over iteration state, such as when you are iterating over a tree structure or a stream that cannot be re-enumerated. However, they are verbose and easy to get wrong, especially around disposal and edge cases.

Using yield to Build Enumerators

C# provides the yield keyword to generate enumerators without writing a separate class. The compiler transforms a method that contains yield return or yield break into a state machine that implements IEnumerator<T> and IEnumerable<T> automatically.

public IEnumerable<int> GetEvenNumbers(int limit) { for (int i = 0; i <= limit; i += 2) { yield return i; } }

When you call GetEvenNumbers, the method body does not execute immediately. Instead, it returns an iterator object that runs the code lazily as MoveNext() is called. Each yield return pauses the method and provides the next value. The compiler-generated state machine handles Current, MoveNext(), and Dispose() for you.

The same approach works for custom collections. You can implement GetEnumerator() using yield:

public class MyList<T> : IEnumerable<T> { private readonly T[] _items; public MyList(T[] items) { _items = items; } public IEnumerator<T> GetEnumerator() { for (int i = 0; i < _items.Length; i++) { yield return _items[i]; } } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); }

This version is far shorter and less error-prone than the manual implementation. The compiler-generated enumerator also supports disposal correctly: if the foreach loop exits early, the generated Dispose() runs any finally blocks inside the iterator method.

How foreach Uses IEnumerator Internally

The compiler expands a foreach loop into a pattern that uses IEnumerator<T> directly. For example, given:

foreach (var item in collection) { Console.WriteLine(item); }

The compiler generates code equivalent to:

var enumerator = collection.GetEnumerator(); try { while (enumerator.MoveNext()) { var item = enumerator.Current; Console.WriteLine(item); } } finally { if (enumerator != null) { enumerator.Dispose(); } }

If the collection is an array, the compiler uses a specialized loop over the array indices instead of allocating an enumerator object. For other types, it relies on the IEnumerator<T> pattern. This is why implementing GetEnumerator() on your type is enough to make it work with foreach. The compiler also supports the duck-typing pattern: if your type has a public GetEnumerator() method that returns a type with Current and MoveNext(), it can use that without implementing the interface. This is how List<T> and other collection types provide high-performance struct enumerators.

Performance and Allocation Considerations

The most common performance issue with enumerators is heap allocation. A class-based enumerator allocates an object on the managed heap each time GetEnumerator() is called. For large collections or frequent iteration, this can add pressure on the garbage collector. The BCL avoids this by using struct enumerators for many collection types. For example, List<T>.Enumerator is a public struct that implements IEnumerator<T>. When you iterate a List<T> with foreach, the compiler sees the concrete GetEnumerator() returning the struct and uses it directly, avoiding boxing and heap allocation.

You can apply the same technique to your own collections by implementing a struct enumerator. The struct must still implement IEnumerator<T>, but because it is a value type, it is typically allocated on the stack or inline. The tradeoff is that structs are copied when passed around, so the enumerator state must be mutable within the struct. Here is a simplified struct enumerator for an array-backed collection:

public struct ArrayEnumerator<T> : IEnumerator<T> { private readonly T[] _items; private int _index; public ArrayEnumerator(T[] items) { _items = items; _index = -1; } public T Current => _items[_index]; object IEnumerator.Current => Current; public bool MoveNext() { _index++; return _index < _items.Length; } public void Reset() => _index = -1; public void Dispose() { } }

When you expose this struct from a GetEnumerator() method, foreach will use it without boxing. However, if you also implement IEnumerable<T>, the explicit interface implementation must return IEnumerator<T>, which forces boxing. To get the performance benefit, the public GetEnumerator() must return the struct type directly, and the class should implement IEnumerable<T> explicitly for compatibility.

Common Pitfalls and Edge Cases

One common mistake is assuming that Reset() will rewind an enumerator. Many implementations throw NotSupportedException because the source is forward-only, such as a network stream or a generator. If you need to iterate multiple times, call GetEnumerator() again rather than relying on Reset().

Another pitfall is modifying the collection during iteration. Most collection enumerators throw InvalidOperationException if the collection is modified after the enumerator is created. This is a safety feature, but it means you cannot add or remove items inside a foreach loop over a List<T>. For concurrent modification, use a thread-safe collection or snapshot the data before iterating.

Disposal is also easy to overlook. If your iterator acquires a resource, such as a file handle or a database connection, you must release it when the enumerator is disposed. The yield compiler generates finally blocks that run on disposal, but manual implementations need to handle this explicitly. If you write a custom enumerator that does not implement Dispose() correctly, resources can leak when the loop exits early.

Finally, be careful with the generic variance. IEnumerator<out T> is covariant, so an IEnumerator<string> can be used as an IEnumerator<object>. This works because Current only returns values and never accepts them. The non-generic IEnumerator is not covariant, so explicit casts may be needed when working with legacy collections.

Choosing Between yield and Manual Implementation

Use yield when you are building an iterator for a method or a simple collection. It is concise, readable, and handles state machine complexities correctly. Manual implementation is appropriate when you need a reusable enumerator type that must be a struct for performance, or when you need to implement additional interfaces or custom behavior that yield cannot express.

For example, if you are building a custom collection that will be iterated frequently in performance-critical code, a struct enumerator can avoid allocations. In that case, you will write the enumerator manually. If you are writing a one-off iterator that filters or transforms a sequence, yield is the better choice because it keeps the logic close to the data source and reduces boilerplate.

The decision also depends on whether you need to preserve iteration state across multiple calls. A yield iterator creates a new state machine each time GetEnumerator() is called, so it is naturally re-iterable. A manual enumerator must be designed to be re-created or reset properly. In most application code, yield is the pragmatic default; manual enumerators are reserved for library authors who need fine-grained control over allocation and behavior.

c# ienumerator: How It Works and How to Use It | RYUSLOG DEV