IEnumerable is one of the most fundamental and powerful interfaces in the .NET ecosystem. Whether you’re working with LINQ, iterating through collections, or building custom data structures, understanding IEnumerable is crucial for writing efficient, elegant C# code. In this comprehensive guide, we’ll explore everything from the basics to advanced techniques that will transform how you work with data in C#. 💻✨
What is IEnumerable?
IEnumerable<T>
is a generic interface that provides a standardized way to iterate over a collection of objects. It’s the foundation that enables the foreach
loop and serves as the backbone for LINQ operations.
public interface IEnumerable<out T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}
The interface is covariant (notice the out
keyword), which means you can assign a more derived type to a less derived type:
IEnumerable<string> strings = new List<string> { "Hello", "World" };
IEnumerable<object> objects = strings; // This works due to covariance
Why this matters: Covariance lets you consume APIs more broadly without casting or re-materializing. Covariance applies only to output positions; you cannot “write into” it (IEnumerable is read-only).
Quick Overview: Which Interface to Use When?
If you need a quick decision on what to use:
- Use
IEnumerable<T>
when you want sequential iteration without indexing. Ideal for pipelines, streams, and LINQ. - Use
IList<T>
orList<T>
when you need index access, insert/remove at arbitrary positions, or capacity control. - Use
ICollection<T>
when you need Count/Add/Remove but not indexing (often a good compromise for APIs). - Use
IQueryable<T>
when the expression should be translated to an external source (e.g., a database) via a LINQ provider. - Use
IAsyncEnumerable<T>
when data arrives asynchronously and in chunks (file/network streaming, API paging) and you want backpressure.
💡 Practical tip: For public API parameters, IEnumerable<T>
is usually the most flexible choice. For return types, choose based on needs: lazy (IEnumerable<T>
) vs. materialized (IReadOnlyList<T>
) vs. asynchronous (IAsyncEnumerable<T>
).
Basic Usage and Implementation
Simple Iteration
IEnumerable<string> names = ["Alice", "Bob", "Charlie"]; // Collection expression (C# 12)
foreach (string name in names)
{
Console.WriteLine(name);
}
While IEnumerable is perfect for iteration, it doesn’t support indexing or random access on its own. If you need indexing, materialize into a concrete collection like a List with ToList() or use APIs that support indexing (for example, arrays or IList).
When should I use IEnumerable<T>
vs. IList<T>
?
- Method parameters: Prefer
IEnumerable<T>
(maximum flexibility, works for streams). If you require indexed access, useIReadOnlyList<T>
orIList<T>
. - Return values: Return
IEnumerable<T>
when you want lazy evaluation; returnIReadOnlyList<T>
/List<T>
when you need a fixed size and random access guarantees.
Creating Custom IEnumerable with yield
The yield
keyword makes implementing IEnumerable incredibly simple:
public static IEnumerable<int> GenerateNumbers(int start, int count)
{
for (int i = 0; i < count; i++)
{
yield return start + i;
}
}
// Usage
foreach (int number in GenerateNumbers(10, 5))
{
Console.WriteLine(number); // 10, 11, 12, 13, 14
}
Advanced Custom Implementation
public record Range(int Start, int End) : IEnumerable<int>
{
public IEnumerator<int> GetEnumerator()
{
for (int i = Start; i <= End; i++)
{
yield return i;
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
// Usage with modern syntax
var range = new Range(1, 5);
var evenNumbers = range.Where(x => x % 2 == 0).ToArray(); // [2, 4]
The Power of Lazy Evaluation
One of IEnumerable’s greatest strengths is lazy evaluation - elements are generated on-demand, not when the sequence is defined: 🎯
public static IEnumerable<string> ReadLinesLazily(string filePath)
{
Console.WriteLine("Starting to read file...");
using var reader = File.OpenText(filePath);
string? line;
while ((line = reader.ReadLine()) is not null)
{
Console.WriteLine($"Reading line: {line}");
yield return line;
}
Console.WriteLine("Finished reading file.");
}
// The file isn't read until you iterate
IEnumerable<string> lines = ReadLinesLazily("large-file.txt");
Console.WriteLine("Sequence created - no file reading yet!");
// Only now does the file get read, and only the first 3 lines
foreach (string line in lines.Take(3))
{
Console.WriteLine($"Processing: {line}");
}
Important: Iterator methods (those using yield return) do not execute their bodies when the method is called. Instead, execution starts when you begin enumerating (for example, the first MoveNext during foreach). This affects not only performance but also when exceptions are thrown, as explained below.
What does this mean in practice? You pay the cost (I/O, CPU) only when you actually enumerate, which is great for large datasets. But side effects and exceptions occur during enumeration; logging and validation may be delayed. If you need deterministic error behavior at call time, materialize (ToList) or validate up front.
LINQ: Where IEnumerable Shines
IEnumerable is the foundation of LINQ, enabling powerful functional programming patterns:
Method Chaining
record Product(string Name, decimal Price, string Category, DateTime ReleaseDate);
var products = new List<Product>
{
new("iPhone 15", 999m, "Electronics", new(2023, 9, 22)),
new("MacBook Pro", 2399m, "Electronics", new(2023, 11, 7)),
new("Coffee Maker", 89m, "Appliances", new(2023, 1, 15)),
new("Running Shoes", 129m, "Sports", new(2023, 6, 1))
};
var recentExpensiveElectronics = products
.Where(p => p.Category == "Electronics")
.Where(p => p.Price > 500)
.Where(p => p.ReleaseDate > new DateTime(2023, 6, 1))
.Select(p => new { p.Name, p.Price, DaysOld = (DateTime.Now - p.ReleaseDate).Days })
.OrderByDescending(p => p.Price);
foreach (var item in recentExpensiveElectronics)
{
Console.WriteLine($"{item.Name}: ${item.Price} ({item.DaysOld} days old)");
}
ℹ️ Note: LINQ operators on IEnumerable<T>
are typically lazy (except e.g., ToList
/ToArray
/Aggregate
/Count
). Be deliberate about where you materialize. Prefer to do it once at a well-chosen boundary.
Advanced LINQ Operations
var numbers = Enumerable.Range(1, 100);
// Complex pipeline with modern C# features
var result = numbers
.Where(n => n % 2 == 0) // Even numbers
.Select(n => (Number: n, Square: n * n)) // Tuple projection
.Where(item => item.Square > 100) // Large squares
.Take(5) // First 5
.ToArray(); // Materialize
Console.WriteLine(string.Join(", ", result.Select(r => $"{r.Number}² = {r.Square}")));
⚡ Performance tip: Sorting (OrderBy
) and grouping (GroupBy
) often materialize internally; plan memory/runtime costs and reduce the set earlier (Where
/Select
/Take
).
Performance Considerations
Understanding Deferred Execution
var data = Enumerable.Range(1, 1_000_000);
// No execution - just building the pipeline
var expensiveQuery = data
.Where(x => IsExpensiveOperation(x))
.Select(x => TransformData(x))
.Where(x => x > 1000);
Console.WriteLine("Query defined, no execution yet");
// Execution happens here
var results = expensiveQuery.Take(10).ToList();
static bool IsExpensiveOperation(int x)
{
// Simulate expensive operation
Thread.Sleep(1);
return x % 100 == 0;
}
static int TransformData(int x) => x * x;
🔗 Think in pipelines: Filter expensive steps early, apply costly projections late, and process only as many items as needed (Take
/FirstOrDefault
).
Avoiding Multiple Enumeration
// ❌ Bad - Multiple enumeration
IEnumerable<int> GetExpensiveData() =>
Enumerable.Range(1, 1000).Where(x => { Thread.Sleep(1); return x > 500; });
var badExample = GetExpensiveData();
if (badExample.Any()) // 1st enumeration
{
var count = badExample.Count(); // 2nd enumeration
var items = badExample.ToList(); // 3rd enumeration
}
// ✅ Good - Single enumeration
var goodExample = GetExpensiveData().ToList();
if (goodExample.Count > 0)
{
var count = goodExample.Count;
var items = goodExample;
}
🛡️ Defenses against accidental multiple enumeration:
- Use .ToList()/.ToArray() intentionally at one point and pass the result along.
- Document in XML docs whether an IEnumerable supports multiple passes.
- Use analyzers that warn about multiple enumeration.
Advanced Techniques
Custom LINQ Extensions
public static class EnumerableExtensions
{
public static IEnumerable<T> TakeEveryNth<T>(this IEnumerable<T> source, int n)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(n); // C# 11+ feature
return source.Where((item, index) => index % n == 0);
}
public static IEnumerable<IEnumerable<T>> Chunk<T>(this IEnumerable<T> source, int size)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size);
using var enumerator = source.GetEnumerator();
while (enumerator.MoveNext())
{
yield return GetChunk(enumerator, size);
}
}
private static IEnumerable<T> GetChunk<T>(IEnumerator<T> enumerator, int size)
{
yield return enumerator.Current;
for (int i = 1; i < size && enumerator.MoveNext(); i++)
{
yield return enumerator.Current;
}
}
public static async IAsyncEnumerable<TResult> SelectAsync<T, TResult>(
this IEnumerable<T> source,
Func<T, Task<TResult>> selector)
{
foreach (var item in source)
{
yield return await selector(item);
}
}
}
// Usage examples
var numbers = Enumerable.Range(1, 20);
var everyThird = numbers.TakeEveryNth(3); // 1, 4, 7, 10, 13, 16, 19
var chunks = numbers.Chunk(5);
foreach (var chunk in chunks)
{
Console.WriteLine($"Chunk: [{string.Join(", ", chunk)}]");
}
Exception timing in iterator methods (important!)
Because methods with yield return are compiled into iterators, code inside those methods runs only when enumeration begins. That means validation that throws (for example, ArgumentOutOfRangeException.ThrowIfNegativeOrZero
) inside an iterator will throw during iteration, not at call time.
Deferred-throw example:
// No exception at call site
var seq = Enumerable.Range(1, 10).TakeEveryNth(0);
try
{
// Exception is thrown here, when enumeration actually starts
foreach (var _ in seq) { }
}
catch (ArgumentOutOfRangeException)
{
Console.WriteLine("Thrown during enumeration");
}
If you prefer eager validation (throwing at the call site), use a small wrapper that performs validation up front and delegates the actual enumeration to a private iterator method:
public static class EnumerableExtensionsEager
{
public static IEnumerable<T> TakeEveryNth<T>(this IEnumerable<T> source, int n)
{
// Eager validation happens at call time
if (source is null) throw new ArgumentNullException(nameof(source));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(n);
return TakeEveryNthImpl(source, n);
}
private static IEnumerable<T> TakeEveryNthImpl<T>(IEnumerable<T> source, int n)
{
var index = 0;
foreach (var item in source)
{
if (index++ % n == 0)
yield return item;
}
}
}
// Now this throws immediately (call site), before any enumeration starts
var _ = Enumerable.Range(1, 10).TakeEveryNth(0); // ArgumentOutOfRangeException
Use the pattern that matches your API expectations. Many .NET BCL APIs throw at call time for argument validation, so mirroring that behavior can be friendlier to consumers.
Rule of thumb: Public APIs should validate parameters immediately (fail fast). Internal iterator implementations may remain lazy as long as the calling API provides the expected error behavior.
Working with IAsyncEnumerable
public static async IAsyncEnumerable<string> ReadLinesAsync(
string filePath,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
using var reader = new StreamReader(filePath);
while (await reader.ReadLineAsync() is { } line)
{
cancellationToken.ThrowIfCancellationRequested();
yield return line;
}
}
// Modern async iteration
await foreach (string line in ReadLinesAsync("large-file.txt"))
{
if (line.Contains("ERROR", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine($"Found error: {line}");
}
}
When should I use IAsyncEnumerable<T>
instead of IEnumerable<T>
or Task<IEnumerable<T>>
?
- When data arrives in chunks and asynchronously (network, file streams, API paging) and you want to start processing early.
- When you need backpressure: the consumer controls the pace via await foreach.
Task<IEnumerable<T>>
typically loads everything before the first await. This is fine for small datasets, but worse for streams or large data.
In practice: For EF Core, use IQueryable<T>
up to the data-access boundary and materialize with ToListAsync
. For true streams (e.g., lines from large files), use IAsyncEnumerable<T>
.
LINQ support for IAsyncEnumerable in .NET 10
ℹ️ Note: Starting with .NET 10, the base class library includes AsyncEnumerable
with built-in LINQ extension methods for IAsyncEnumerable<T>
. For details, see the .NET docs issue: https://github.com/dotnet/docs/issues/44886
Modern C# Features with IEnumerable
Pattern Matching and Switch Expressions
public static string DescribeCollection<T>(IEnumerable<T> collection) => collection switch
{
[] => "Empty collection",
[var single] => $"Single item: {single}",
[var first, var second] => $"Two items: {first}, {second}",
[var first, .., var last] => $"Multiple items from {first} to {last}",
_ => "Unknown collection type"
};
// Usage
Console.WriteLine(DescribeCollection(new[] { 1, 2, 3, 4, 5 })); // "Multiple items from 1 to 5"
Using Collection Expressions
// C# 12 collection expressions
IEnumerable<int> numbers = [1, 2, 3, 4, 5];
IEnumerable<string> words = ["hello", "world"];
// Combining collections
var combined = [..numbers.Select(n => n.ToString()), ..words];
Note: Collection expressions typically create new collections. Use them intentionally. In hot paths, a pure iterator pipeline can be cheaper.
Best Practices and Tips
Before reaching for ToList/ToArray, consider whether you really need a materialized collection. Staying lazy longer can save memory and time; materialize once at the boundary where you need indexing, random access, or repeated enumeration.
1. Prefer IEnumerable for Method Parameters
// ✅ Flexible - accepts any enumerable type
public void ProcessItems(IEnumerable<string> items)
{
foreach (var item in items)
{
// Process each item
}
}
// ❌ Restrictive - only accepts lists
public void ProcessItems(List<string> items) { }
📏 Guideline: For input parameters prefer IEnumerable<T>
; for outputs choose based on contract IReadOnlyList<T>
/IEnumerable<T>
. Use IReadOnlyCollection<T>
when you want to promise an efficient Count
.
2. Use ToList() Judiciously
// ❌ Unnecessary materialization
var result = data
.Where(x => x > 0)
.ToList()
.Where(x => x < 100)
.ToList();
// ✅ Chain operations, materialize once
var result = data
.Where(x => x > 0)
.Where(x => x < 100)
.ToList();
Materialize at system boundaries: before UI bindings, across thread/task boundaries, before multiple passes, or when you need random access.
3. Leverage yield for Memory Efficiency
// ✅ Memory efficient
public static IEnumerable<long> GenerateFibonacci(int count)
{
if (count <= 0) yield break;
long a = 0, b = 1;
for (int i = 0; i < count; i++)
{
yield return a;
(a, b) = (b, a + b);
}
}
// ❌ Memory intensive
public static List<long> GenerateFibonacciList(int count)
{
var result = new List<long>(count);
long a = 0, b = 1;
for (int i = 0; i < count; i++)
{
result.Add(a);
(a, b) = (b, a + b);
}
return result;
}
📏 Guideline: yield
is ideal when elements are computed on-the-fly and not needed again. For reuse or sorting, materialize first.
Debugging and Testing
Adding Debug Visibility
public static class DebuggingExtensions
{
public static IEnumerable<T> Debug<T>(this IEnumerable<T> source, string? label = null)
{
var prefix = label is not null ? $"[{label}] " : "";
foreach (var item in source)
{
Console.WriteLine($"{prefix}Processing: {item}");
yield return item;
}
}
}
// Usage
var result = Enumerable.Range(1, 10)
.Debug("Input")
.Where(x => x % 2 == 0)
.Debug("After Filter")
.Select(x => x * 2)
.Debug("After Transform")
.ToList();
💡 Tip: Logging inside iterators changes timing. Use debug helpers sparingly and remove or feature-flag them in production.
Testing IEnumerable Methods
[Test]
public void TestCustomEnumerable()
{
// Arrange
var range = new Range(1, 5);
// Act
var result = range.Where(x => x % 2 == 0).ToArray();
// Assert
result.Should().BeEquivalentTo([2, 4]);
}
🧪 Test notes:
- Test both lazy (not materialized) and materialized paths.
- Decide on deterministic error behavior: where should exceptions occur (call time vs. enumeration)?
- Ensure pipelines aren’t enumerated multiple times (e.g., via mocks/counters).
Real-World Example: Data Pipeline
Here’s a practical example showing IEnumerable in action with a data processing pipeline:
public record LogEntry(DateTime Timestamp, string Level, string Message, string Source);
public static class LogProcessor
{
public static IEnumerable<LogEntry> ParseLogFile(string filePath)
{
return File.ReadLines(filePath)
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(ParseLogLine)
.Where(entry => entry is not null)!;
}
public static IEnumerable<LogEntry> FilterErrors(this IEnumerable<LogEntry> entries) =>
entries.Where(e => e.Level.Equals("ERROR", StringComparison.OrdinalIgnoreCase));
public static IEnumerable<IGrouping<string, LogEntry>> GroupBySource(this IEnumerable<LogEntry> entries) =>
entries.GroupBy(e => e.Source);
private static LogEntry? ParseLogLine(string line)
{
// Simplified parsing logic
var parts = line.Split(' ', 4);
if (parts.Length < 4) return null;
if (!DateTime.TryParse($"{parts[0]} {parts[1]}", out var timestamp))
return null;
return new LogEntry(timestamp, parts[2], parts[3], "Unknown");
}
}
// Usage
var errorsBySource = LogProcessor.ParseLogFile("application.log")
.FilterErrors()
.GroupBySource()
.ToDictionary(g => g.Key, g => g.Count());
foreach (var error in errorsBySource)
{
Console.WriteLine($"{error.Key}: {error.Value} errors");
}
🧩 Extension idea: Extend ParseLogLine
to extract a source (source=…) from the log. Then GroupBySource
becomes truly meaningful. For large logs, File.ReadLines
is ideal (streamed). For asynchronous processing, you can lift the pipeline to IAsyncEnumerable<LogEntry>
.
Conclusion
IEnumerable is far more than just a way to iterate through collections; it’s the cornerstone of functional programming in C#. Its lazy evaluation capabilities, seamless LINQ integration, and extensive customization options make it an indispensable tool for any C# developer. 🔧✨
Key takeaways:
- Lazy evaluation enables memory-efficient and performant data processing ⚡
- LINQ integration provides powerful functional programming capabilities 🎯
- yield return simplifies custom enumerable creation 🔄
- Modern C# features like pattern matching and collection expressions enhance usability 🆕
- Performance awareness is crucial for production applications ⚖️
By mastering IEnumerable, you’ll write more elegant, efficient, and maintainable C# code. Whether you’re processing large datasets, building data pipelines, or creating custom collections, IEnumerable provides the foundation for clean, functional programming in C#. 🚀
Happy coding! 💻
Johannes-Max 👨💻