📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is IEnumerable<T> really? Explain the IEnumerable/IEnumerator contract, how foreach is compiled (including the pattern-based GetEnumerator and struct enumerators), enumerator disposal, IAsyncEnumerable<T>, and when a method should accept or return IEnumerable<T>.

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.

Mid-Level Theory Intermediate async-streams csharp ienumerable iterators linq

🎯 Key points

  • `IEnumerable<T>` has one method, `GetEnumerator()`; `IEnumerator<T>` has `MoveNext()`, `Current` and `Dispose()` (Reset is legacy)
  • foreach is pattern-based: any type with a suitable `GetEnumerator()` works, including extension GetEnumerator since C# 9
  • foreach calls Dispose in a finally block, which is how iterator finally blocks and using statements run
  • `List<T>` has a struct enumerator; iterating through `IEnumerable<T>` boxes it and adds interface calls
  • An IEnumerable may be lazy, infinite, expensive or single-use; it promises only forward iteration
  • `IAsyncEnumerable<T>` with `await foreach` streams asynchronously; cancellation via WithCancellation and [EnumeratorCancellation]
  • Accept the least capable type you need (IEnumerable<T>); return a materialised read-only type (IReadOnlyList<T>) unless streaming is intended

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. The contract

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
}
  • Each call to GetEnumerator() should return a new, independent cursor.
  • Current is valid only after MoveNext() returned true.
  • Enumerators are disposable — iterators and database readers release resources in Dispose.
  • An 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.

2. How foreach is compiled

foreach (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:

  • Pattern-based: 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).
  • Struct enumerators: 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.
  • Arrays and spans compile to for loops with no enumerator.
  • Disposal happens in finally, so break/exceptions still run iterator finally blocks and close readers.
  • Version checks in BCL collections throw if the collection is modified during enumeration.

3. 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.
  • Used for streaming database results, paging through APIs, gRPC streams, SignalR streams, and ASP.NET Core can stream IAsyncEnumerable<T> JSON responses.

4. When to accept 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).

5. When to return 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.

6. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Iterator methods implement the contract

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().

2. Struct enumerator performance

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).

3. Reset() and repeatability

Reset() 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.

4. IAsyncEnumerable<T> internals

Async 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].

5. Covariance

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.

6. Choosing types for APIs

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

Try it yourself

  1. Implement a type with only a pattern-based GetEnumerator() (no interfaces) and use it in foreach.
  2. Benchmark foreach over List<int> vs the same list as IEnumerable<int> with [MemoryDiagnoser].
  3. Write an iterator with a try/finally and break early from foreach to see finally run.
  4. Write an async iterator with [EnumeratorCancellation] and cancel it mid-stream.

💻 Code

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");
    }
}

⚠ Common mistakes

1. Assuming 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.

2. Multiple enumeration

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.

3. Returning unexecuted queries

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>.

4. Manual enumeration without disposal

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.

5. Relying on Reset()

What happens: calling Reset() to restart enumeration. Why it is wrong: throws for most enumerators. Do instead: call GetEnumerator() again.

6. Interface enumeration in hot loops

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.

7. Forgetting cancellation in async streams

What happens: async iterators without [EnumeratorCancellation]. Why it is wrong: WithCancellation tokens are ignored. Do instead: annotate the token parameter.

8. Modifying collections during foreach

What happens: adding/removing items while iterating. Why it is wrong: InvalidOperationException from version checks. Do instead: iterate a snapshot or use RemoveAll.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says IEnumerable may hide a database query. Why: IQueryable extends IEnumerable; tests where queries run.
    How to answer

    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.

  2. When: Candidate explains foreach lowering. Why: Checks how foreach compiles for arrays, lists and other types.
    How to answer

    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.

  3. When: Candidate recommends IReadOnlyList<T> return types. Why: API design: which collection types to accept and return.
    How to answer

    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>.

🛠 Real-world problems: how you handle them

CSV export read the database twice
Situation
A finance export service returned IEnumerable<Row> built with yield over a SqlDataReader. The API layer showed progress by calling Count() before iterating.
Signals
Exports took twice as long as the query; SQL Server Query Store showed the export query executing twice per request.
Investigation
Put a breakpoint in the iterator and saw it start twice; traced the first start to Count() in the API layer.
Resolution
Changed the service to return IAsyncEnumerable<Row> for streaming and added a separate CountAsync query for progress; the API streamed rows directly to the response.
Prevention
Enabled CA1851 as a warning and documented that service methods return either materialised lists or explicitly streaming types.

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.

↩ Usually asked after

More in LINQ

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me