Back to Blog
C#

C# IEnumerable: How It Works and When to Use It

c# ienumerable: Learn how IEnumerable<T> works in C#, including deferred execution, yield return, and when to choose it over List or IQueryable.

C#IEnumerableLINQDeferred ExecutionLazy Evaluation
Illustration of C# IEnumerable representing a lazy sequence with deferred execution.

c# ienumerable requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

The IEnumerable<T> interface is central to working with sequences in C#. It defines a way to iterate over a collection without specifying how that collection is stored or materialized. When you write LINQ queries or pass collections to methods, you're often working with IEnumerable<T> without thinking about its underlying behavior. Understanding how it works—especially its deferred execution—can prevent subtle bugs and help you write more efficient code.

The IEnumerable and IEnumerator Pattern

At its core, IEnumerable<T> exposes a single method: GetEnumerator(), which returns an IEnumerator<T>. The enumerator provides Current, MoveNext(), and Reset() (though Reset() is rarely used and often throws NotSupportedException). This pattern is what enables foreach loops to work on any type that implements IEnumerable<T>.

The interface itself does not guarantee that the data is stored in memory. It only guarantees that you can request an enumerator and step through the sequence. This abstraction is what allows LINQ to operate on databases, in-memory collections, and even infinite sequences without knowing the underlying source.

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

The out keyword marks the interface as covariant, meaning an IEnumerable<string> can be used as an IEnumerable<object>. This is why you can pass a List<string> to a method expecting IEnumerable<object>.

How Deferred Execution Works

One of the most important behaviors of IEnumerable<T> is deferred execution. When you write a LINQ query using methods like Where, Select, or OrderBy, the query is not executed at the point of definition. Instead, it is executed when you start enumerating the sequence.

var numbers = new List<int> { 1, 2, 3, 4, 5 }; var evenNumbers = numbers.Where(n => n % 2 == 0); // No execution yet // Execution happens here, when we iterate foreach (var n in evenNumbers) { Console.WriteLine(n); }

This deferred execution is a direct consequence of how IEnumerable<T> works. The LINQ methods return an iterator that captures the source and the predicate, but they do not run the predicate until MoveNext() is called. This has practical implications: if the source collection changes between the query definition and the enumeration, the query will see the updated data.

var list = new List<int> { 1, 2, 3 }; var query = list.Where(x => x > 1); list.Add(4); // Change the source foreach (var item in query) // Now enumerates over { 2, 3, 4 } { Console.WriteLine(item); }

This behavior is desirable when you want to keep the query as a description of what to do, rather than a snapshot of the data at a point in time. However, it can lead to surprising results if you don't expect it.

Implementing IEnumerable<T> with yield return

When you need to create a custom sequence, the yield return keyword simplifies the implementation of IEnumerable<T>. The compiler generates a state machine that implements both IEnumerable<T> and IEnumerator<T>, handling the deferred execution for you.

public IEnumerable<int> GetFibonacci(int count) { int previous = 0; int current = 1; for (int i = 0; i < count; i++) { yield return previous; int next = previous + current; previous = current; current = next; } }

Each call to MoveNext() advances the state machine to the next yield return. The method body does not execute until you start enumerating, and it pauses at each yield statement. This is how you can produce an infinite sequence without exhausting memory:

public IEnumerable<int> GetNaturalNumbers() { int n = 0; while (true) { yield return n++; } }

When you use yield return, the compiler creates a hidden class that implements the enumerator. You don't have to manually manage the Current property or MoveNext() logic. This reduces boilerplate and makes custom iterators much easier to write correctly.

Common Pitfalls: Multiple Enumeration and Side Effects

Because IEnumerable<T> is lazy, enumerating the same sequence multiple times can have unexpected consequences. If the sequence is generated from a database query or a file stream, each enumeration may re-execute the underlying operation. This can cause performance issues or inconsistent results if the source changes between enumerations.

public IEnumerable<int> GetNumbersFromDatabase() { // Imagine this executes a SQL query return ExecuteQuery(); } var numbers = GetNumbersFromDatabase(); var firstCount = numbers.Count(); // Executes the query var secondCount = numbers.Count(); // Executes the query again

To avoid this, you can materialize the sequence into a list or array when you know you'll need to iterate it multiple times. Use ToList() or ToArray() to create a snapshot.

Another pitfall is side effects inside a LINQ predicate. If your predicate modifies state or performs I/O, deferred execution means those side effects happen at enumeration time, not at query definition time. This can make debugging difficult because the side effects occur later than expected.

var log = new List<string>(); var numbers = new[] { 1, 2, 3 }; var query = numbers.Where(n => { log.Add($"Checking {n}"); return n > 1; }); // log is empty here var result = query.ToList(); // Side effects happen now

Performance: When IEnumerable Is the Right Choice

IEnumerable<T> is ideal for streaming data when you don't need the entire collection in memory at once. For example, reading a large file line by line or processing a stream of sensor data can be done with a custom iterator that yields one element at a time. This keeps memory usage low and allows you to start processing before the entire sequence is available.

However, IEnumerable<T> has overhead compared to a simple array or list. Each MoveNext() call involves a virtual method call and often a state machine transition. For small in-memory collections, this overhead is negligible, but in performance-critical loops, a for loop over an array can be faster.

Consider the following tradeoff:

ScenarioUse IEnumerable<T>Use List<T> or Array
Large data streamYesNo
Multiple enumerations neededNoYes
Random access by indexNoYes
Minimal memory footprintYesNo
Simple iteration over in-memory dataSometimesYes

If you need random access, IEnumerable<T> is not suitable because it only supports forward iteration. In that case, use List<T> or an array. If you need to pass a collection to a method that only iterates it once, IEnumerable<T> is a good choice because it decouples the method from the concrete collection type.

IEnumerable vs IQueryable: Which One to Use

IEnumerable<T> and IQueryable<T> are often confused because both work with LINQ. The key difference is where the query is executed. IEnumerable<T> executes in memory, while IQueryable<T>, when used with a provider like Entity Framework, can translate the query expression into SQL and execute it on the database server.

// IEnumerable: filters in memory after fetching all records IEnumerable<Customer> customers = dbContext.Customers; var filtered = customers.Where(c => c.Age > 30); // Runs LINQ to Objects // IQueryable: translates to SQL WHERE clause IQueryable<Customer> queryable = dbContext.Customers; var filteredSql = queryable.Where(c => c.Age > 30); // SQL: WHERE Age > 30

If you use IEnumerable<T> with a database, you force the entire table to be loaded into memory before filtering. This can be a major performance problem. Use IQueryable<T> when you want the query to be composed at the database level. Once you call ToList() or ToArray(), the data is materialized and you get an IEnumerable<T> that operates in memory.

A common mistake is to convert an IQueryable<T> to IEnumerable<T> too early, losing the ability to push filters down to the database. Keep the query as IQueryable<T> as long as possible, and only materialize when you need the results.

Practical Usage Patterns in Application Code

In everyday C# code, you'll often see methods that accept IEnumerable<T> as a parameter. This is good design because it allows callers to pass any collection type—arrays, lists, sets, or even lazy sequences. However, be aware that the method might enumerate the sequence multiple times. To make the method robust, you can materialize the input if you need to iterate it more than once.

public void ProcessItems(IEnumerable<Item> items) { // If you need to iterate twice, materialize first var itemList = items.ToList(); foreach (var item in itemList) { // First pass } foreach (var item in itemList) { // Second pass } }

Another pattern is returning IEnumerable<T> from a method to indicate that the caller can stream the results. This is useful for large datasets, but it also means the caller must be aware that the sequence is lazy. Document this behavior in the method's XML comments so callers know they can't rely on the sequence being a snapshot.

When you combine yield return with try/finally, the finally block executes when the enumerator is disposed. This is useful for releasing resources like file handles or database connections. The compiler-generated iterator handles this correctly, but only if the caller disposes the enumerator (which foreach does automatically).

public IEnumerable<string> ReadLines(string path) { using (var reader = new StreamReader(path)) { string line; while ((line = reader.ReadLine()) != null) { yield return line; } } // The reader is disposed when enumeration completes or is abandoned }

Understanding these patterns helps you write APIs that are both flexible and efficient. By choosing IEnumerable<T> deliberately, you control when data is materialized and how much memory your application uses.

c# ienumerable: Practical Usage and Code Examples | RYUSLOG DEV