IEnumerable<T> and IEnumerator<T> in depth: the contract, foreach lowering, struct enumerators and boxing, disposal, async streams, and API design guidance on accepting and returning sequences.
IEnumerable<T> is just a promise that you can get an enumerator. IEnumerator<T> gives MoveNext, Current and Dispose, and that is all: no count, no indexing, and possibly lazy, infinite or single-use. foreach is pattern-based and compiles to a while (MoveNext()) loop with Dispose in a finally. List<T> uses a struct enumerator, which gets boxed if you iterate through the interface. IAsyncEnumerable<T> is the async version, used with await foreach. I accept IEnumerable<T> when I only iterate once, and return IReadOnlyList<T> unless I mean to stream.
IEnumerable<T> is a tiny contract: "I can give you an enumerator that walks a sequence forward, once." It promises nothing about count, indexing, repeatability, cost or whether data is in memory. I'd explain the IEnumerable/IEnumerator contract, how foreach is compiled (including pattern-based enumerators and struct enumerators), disposal, IAsyncEnumerable<T>, and API design guidance.
public interface IEnumerable<out T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}
public interface IEnumerator<out T> : IDisposable, IEnumerator
{
T Current { get; }
bool MoveNext(); // advance; false when finished
void Reset(); // legacy; usually throws NotSupportedException
}
GetEnumerator() should return a new, independent cursor.Current is valid only after MoveNext() returned true.Dispose.IEnumerable<T> may be a materialised collection, a lazy LINQ pipeline, an iterator method, a file reader (File.ReadLines) or a database query — the interface doesn't tell you.foreach is compiledforeach (var order in orders) Process(order);
becomes:
var e = orders.GetEnumerator();
try
{
while (e.MoveNext()) { var order = e.Current; Process(order); }
}
finally
{
e?.Dispose();
}
Key details:
foreach doesn't require IEnumerable<T> — any type with an accessible GetEnumerator() returning a type with MoveNext() and Current works (and since C# 9, an extension GetEnumerator).List<T>, Dictionary<K,V>, HashSet<T>, ImmutableArray<T> expose public GetEnumerator() methods returning structs, so foreach over the concrete type allocates nothing and calls can be inlined. Through the IEnumerable<T> interface, the struct is boxed and calls become interface calls.for loops with no enumerator.finally, so break/exceptions still run iterator finally blocks and close readers.IAsyncEnumerable<T>public async IAsyncEnumerable<Order> StreamOrdersAsync([EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var order in db.Orders.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
yield return order;
}
await foreach (var o in service.StreamOrdersAsync(ct)) { ... }
MoveNextAsync() returns ValueTask<bool> so each step can await I/O without blocking.DisposeAsync() releases resources asynchronously.[EnumeratorCancellation] passes the token from WithCancellation into the iterator.IAsyncEnumerable<T> JSON responses.IEnumerable<T>Accept it when the method iterates once and needs nothing else — maximum flexibility for callers (lists, arrays, LINQ, iterators all work).
If the method needs Count, indexing, or iterates more than once, accept IReadOnlyCollection<T>/IReadOnlyList<T> instead — otherwise you risk multiple enumeration of a lazy/expensive source (CA1851).
IEnumerable<T>Return it when the result is genuinely lazy/streaming (iterator methods, pipelines) and document that. Return IReadOnlyList<T> (or IReadOnlyCollection<T>, arrays, immutable collections) for materialised results — callers then know it's cheap to count and re-iterate, and you avoid the "query escaped its DbContext" bug. Return IAsyncEnumerable<T> for async streaming.
A service method IEnumerable<Order> GetOrders() returned an EF query; a caller did if (orders.Any()), orders.Count(), then foreach — three database round trips — and a background job later enumerated it after the request scope ended (ObjectDisposedException). We changed the signature to Task<IReadOnlyList<OrderDto>> GetOrdersAsync(CancellationToken), materialising inside, and added an IAsyncEnumerable<OrderDto> StreamOrdersAsync for the export path. The types now told callers exactly what they were getting.
Checks deep understanding of the sequence abstraction behind LINQ and foreach.
Green flags: exact contract; foreach lowering with Dispose; pattern-based foreach; struct enumerators; IAsyncEnumerable with cancellation; API guidance on parameters versus returns.
Red flags: thinks IEnumerable is a collection with a count; returns lazy queries everywhere; unaware of disposal semantics.
A method with yield return compiles to a class implementing both IEnumerable<T> and IEnumerator<T>. The first GetEnumerator() call on the same thread returns this (an optimisation); later calls create new instances, so each enumeration restarts the method from the top. finally blocks become part of Dispose().
List<int> list = ...;
foreach (var x in list) { } // List<int>.Enumerator (struct), no allocation
foreach (var x in (IEnumerable<int>)list) { } // boxed enumerator (allocation) + interface calls
Benchmarks typically show the interface path several times slower in tight loops. Dynamic PGO can devirtualise the interface calls when the type is consistent, narrowing (not eliminating) the gap. For hot internal code, use concrete types or spans (CollectionsMarshal.AsSpan).
Reset() and repeatabilityReset() is a COM-era leftover; iterator-generated enumerators throw NotSupportedException. Never rely on it — call GetEnumerator() again. Not all sequences are repeatable: File.ReadLines reopens the file (OK but costly), IEnumerable over a network stream may not be.
IAsyncEnumerable<T> internalsAsync iterators compile to a state machine implementing IAsyncEnumerable<T>, IAsyncEnumerator<T> and IValueTaskSource<bool> — a reusable object avoiding allocations per MoveNextAsync. ConfigureAwait(false) and WithCancellation are applied via ConfiguredCancelableAsyncEnumerable<T>. Cancellation must be threaded through with [EnumeratorCancellation].
IEnumerable<out T> is covariant: IEnumerable<string> is usable as IEnumerable<object> without copying. This is part of why accepting IEnumerable<T> is flexible. IAsyncEnumerable<out T> is covariant too.
| Situation | Parameter | Return |
|---|---|---|
| Iterate once | IEnumerable<T> |
— |
| Need count/index/multiple passes | IReadOnlyCollection<T> / IReadOnlyList<T> |
IReadOnlyList<T> |
| Lazy pipeline / large stream | IEnumerable<T> |
IEnumerable<T> (documented) |
| Async stream | IAsyncEnumerable<T> |
IAsyncEnumerable<T> |
| Hot internal path | ReadOnlySpan<T> |
span or array |
GetEnumerator() (no interfaces) and use it in foreach.foreach over List<int> vs the same list as IEnumerable<int> with [MemoryDiagnoser].try/finally and break early from foreach to see finally run.[EnumeratorCancellation] and cancel it mid-stream.using System.Runtime.CompilerServices;
namespace Linq.Enumerables;
// Pattern-based foreach: no interface needed
public readonly struct Countdown(int from)
{
public Enumerator GetEnumerator() => new(from);
public struct Enumerator(int from)
{
private int _current = from + 1;
public readonly int Current => _current;
public bool MoveNext() => --_current >= 0;
}
}
public static class Sources
{
public static IEnumerable<string> ReadLines(string path)
{
using var reader = new StreamReader(path); // disposed when the consumer stops
try
{
while (reader.ReadLine() is { } line) yield return line;
}
finally
{
Console.WriteLine("reader closed");
}
}
public static async IAsyncEnumerable<int> PagesAsync(int pages, [EnumeratorCancellation] CancellationToken ct = default)
{
for (var page = 1; page <= pages; page++)
{
await Task.Delay(10, ct); // simulate an HTTP page fetch
yield return page;
}
}
}
public static class Program
{
public static async Task Main()
{
foreach (var n in new Countdown(3)) Console.Write($"{n} ");
Console.WriteLine();
var path = Path.GetTempFileName();
await File.WriteAllLinesAsync(path, ["header", "row1", "row2"]);
Console.WriteLine(Sources.ReadLines(path).First()); // prints header, then "reader closed"
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await foreach (var page in Sources.PagesAsync(3).WithCancellation(cts.Token))
Console.WriteLine($"page {page}");
IEnumerable<int> lazy = Enumerable.Range(1, 5).Select(x => x * 2);
Console.WriteLine(lazy.TryGetNonEnumeratedCount(out var count) ? $"known {count}" : "unknown without enumerating");
}
}
IEnumerable<T> means "in memory"What happens: treating any IEnumerable<T> as a cheap list.
Why it is wrong: it may be a lazy query, file read or database call.
Do instead: accept/return more specific types when you need guarantees.
What happens: calling Count(), Any() and foreach on an IEnumerable<T> parameter.
Why it is wrong: repeated work or queries.
Do instead: accept IReadOnlyCollection<T> or materialise once.
What happens: returning IEnumerable<T> built over a scoped resource.
Why it is wrong: executes after disposal or multiple times.
Do instead: return materialised IReadOnlyList<T> or deliberately stream with IAsyncEnumerable<T>.
What happens: var e = seq.GetEnumerator(); while (e.MoveNext()) ....
Why it is wrong: iterator finally blocks and readers don't run/close.
Do instead: use foreach or using var e.
Reset()What happens: calling Reset() to restart enumeration.
Why it is wrong: throws for most enumerators.
Do instead: call GetEnumerator() again.
What happens: iterating lists through IEnumerable<T> in tight loops.
Why it is wrong: boxing and interface dispatch.
Do instead: use concrete types or spans internally.
What happens: async iterators without [EnumeratorCancellation].
Why it is wrong: WithCancellation tokens are ignored.
Do instead: annotate the token parameter.
foreachWhat happens: adding/removing items while iterating.
Why it is wrong: InvalidOperationException from version checks.
Do instead: iterate a snapshot or use RemoveAll.
IEnumerable is the weakest useful contract, which is exactly why it is a good parameter type and a risky return type. Seniors are explicit about cost and lifetime: they return materialised read-only collections from services, use IAsyncEnumerable<T> deliberately for streaming, and know that foreach disposal is what makes resource-owning iterators safe. Knowing the struct-enumerator boxing detail shows depth, but I only act on it when a profiler says so.
IQueryable<T> is an IEnumerable<T> that also carries an expression tree and a provider. Queryable.Where takes an Expression<Func<T,bool>> and just wraps the expression, and when you enumerate, the provider, for example EF Core, translates the whole tree, typically into SQL. IEnumerable operators take delegates and run in memory. Which one you get depends on the static type of the variable, so storing a DbSet in an IEnumerable variable quietly moves every later Where into memory. AsEnumerable() does that on purpose. I keep IQueryable inside the data layer and return materialised results.
C# has selection statements (if, switch), iteration (for, foreach, while, do) and jumps (break, continue, return, goto, throw, yield). foreach over an array compiles to an index loop; over anything else it calls GetEnumerator, loops on MoveNext/Current, and disposes the enumerator in a finally. The bugs I watch for are modifying a list while iterating (it throws), closures capturing a for loop variable (they all see the last value), off-by-one bounds, and enumerating a lazy LINQ query twice.
Choose by access pattern, then mutability, then concurrency. Index access or iteration: List<T>/array. Lookup by key: Dictionary. Uniqueness or membership: HashSet. Order of processing: Queue/Stack/PriorityQueue. Sorted keys: SortedList (read-mostly) or SortedDictionary (write-heavy). Shared across threads: ConcurrentDictionary/Channel<T>, or an immutable/frozen snapshot if writes are rare. Built once and read forever: FrozenDictionary/FrozenSet/ImmutableArray. For APIs, accept the least specific type you need (IEnumerable<T>, IReadOnlyCollection<T>, IReadOnlyList<T>, or ReadOnlySpan<T>) and return a specific read-only type (IReadOnlyList<T>, IReadOnlyDictionary, or an immutable type), not a mutable List<T>.
How to tell it (STAR)
**S:** Large finance exports were taking twice as long as they should. **T:** Find the bottleneck. **A:** I traced it to a lazy IEnumerable being enumerated twice, once for a count. I split the count into its own query and made the export an explicit async stream. **R:** Export time halved and memory stayed flat for [size] exports.
Last updated 30 Oct 2026.