Start Debugging

IEnumerable vs IAsyncEnumerable vs IQueryable in C#: which one should the method return?

Three sequence interfaces, three execution models. Use IQueryable when a database can translate the query, IAsyncEnumerable when the producer is async and you want to stream, IEnumerable for everything else in memory.

If you are picking between IEnumerable<T>, IAsyncEnumerable<T>, and IQueryable<T> for a method signature in C# 14 / .NET 11, the rule is almost mechanical. Return IQueryable<T> only when the consumer can compose more Where/Select/OrderBy calls and the underlying provider (EF Core 11, LINQ to SQL, an OData client) can translate them into the remote query. Return IAsyncEnumerable<T> when the producer does I/O per item or per batch and you want the consumer to start processing before the producer is done. Return IEnumerable<T> for everything that is already in memory or that you have decided to fully materialize at the boundary. The mistake to avoid is leaking IQueryable<T> out of a repository: every subsequent .Where(...) becomes part of the SQL whether you wanted it to or not, and “where does this query actually run” becomes a question you have to answer with the debugger.

This post is the long version. Every example targets <TargetFramework>net11.0</TargetFramework> with <LangVersion>14.0</LangVersion> and, where relevant, Microsoft.EntityFrameworkCore 11.0.0.

Three interfaces, three execution models

The three interfaces look similar on paper. They all expose a single sequence of T. The difference is where the work happens and when.

The most important consequence: an IEnumerable<T> returned by an EF Core call has already left the database. An IQueryable<T> returned by the same call has not. That single fact is responsible for more “why is this query slow” tickets than any other single cause in EF Core code.

Feature matrix

CapabilityIEnumerable<T>IAsyncEnumerable<T>IQueryable<T>
Execution modelsync pullasync pulldeferred, translated by a provider
Where does the work runcalling thread, in-memoryproducer side, awaitableremote provider (DB, OData, Cosmos)
Can await between itemsnoyesn/a (no per-item work)
LINQ operators availableLINQ to ObjectsLINQ to Objects (Async)provider-specific subset
Composable after returnyes (in-memory)yes (in-memory)yes (translated remotely)
Streams without bufferingyes (lazy yield return)yesdepends on the provider
Cancellationnone, the loop is syncCancellationToken per itemper query via ToListAsync(token)
Risk when returned from a repositorylowmedium (lifetime of provider)high (caller can append SQL)
Best fitin-memory collectionsremote streams, server-sentrepository-internal query objects
Materializes whenon each MoveNexton each await MoveNextAsyncon terminal operator

The matrix is the post. Everything below is the reasoning.

When IEnumerable<T> is the right return type

IEnumerable<T> is the default for “I have items, give me a sequence”. It is sync, it has every LINQ-to-Objects operator, and it composes cheaply. Use it for:

The trap is using IEnumerable<T> as the return type of a repository method that wraps an async I/O call. That forces the repository to do .ToList() internally and lose the streaming property, or it forces the caller into .Result and a thread-pool block. Both are wrong. If the source is async, the signature should be IAsyncEnumerable<T> or Task<List<T>>, not IEnumerable<T>.

// .NET 11, C# 14
public static IEnumerable<string> ReadLowercaseLines(string path)
{
    foreach (var line in File.ReadLines(path))
    {
        yield return line.ToLowerInvariant();
    }
}

File.ReadLines returns an IEnumerable<string> that lazily reads the file. The transform stays lazy. Nothing forces the file to be fully loaded before the first item reaches the caller.

The yield return keyword is what makes this work. It tells the compiler to generate a state machine that returns items one at a time, suspending the method between yields. It is the synchronous mirror of await foreach plus yield return together.

When IAsyncEnumerable<T> is the right return type

IAsyncEnumerable<T> is what you reach for when the producer needs to await between items. The cardinal example is a paged HTTP endpoint: you fetch page 1, yield each item, fetch page 2, yield each item. You want the consumer to start work on page 1 while page 2 is still in flight. You also want a CancellationToken plumbed in so the consumer can stop the producer cleanly.

Use it for:

// .NET 11, C# 14
public static async IAsyncEnumerable<Order> FetchAllAsync(
    HttpClient http,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    string? next = "/api/orders?page=1";
    while (next is not null)
    {
        cancellationToken.ThrowIfCancellationRequested();
        var page = await http.GetFromJsonAsync<PageOf<Order>>(next, cancellationToken)
                   ?? throw new InvalidOperationException("page was null");
        foreach (var order in page.Items)
        {
            yield return order;
        }
        next = page.NextLink;
    }
}

Two details that catch people. First, [EnumeratorCancellation] is required to wire the token from WithCancellation(...) at the call site into the iterator. Without it, calling await foreach (var x in source.WithCancellation(token)) silently drops the token. Second, an async iterator method cannot use try/catch around a yield return for an exception that comes from a downstream operator; the exception flows through the consumer, not the producer. Wrap the I/O calls explicitly when you need retry logic.

For EF Core 11, the equivalent on a DbSet<T> is AsAsyncEnumerable:

// .NET 11, C# 14, EF Core 11.0.0
await foreach (var order in db.Orders
    .Where(o => o.Status == "shipped")
    .AsAsyncEnumerable()
    .WithCancellation(cancellationToken))
{
    await sink.WriteAsync(order, cancellationToken);
}

That keeps the SQL data reader open and pulls rows on demand. The full set never sits in List<Order>. For details on the EF Core specifics, see how to use IAsyncEnumerable with EF Core 11.

When IQueryable<T> is the right return type

IQueryable<T> is the right shape inside a repository or a query-building helper, where the caller is still expected to compose. It is the wrong shape across a network boundary or out of a layer that the next caller might not understand.

Use it for:

The pattern that bites is exposing IQueryable<T> from a service layer that the caller assumes returns in-memory data:

// Anti-pattern: do not return IQueryable<T> from a public service
public IQueryable<Order> GetRecentOrders() => _db.Orders.Where(o => o.At > _start);

// Caller, miles away
var bad = service.GetRecentOrders()
                 .Where(o => SomeLocalMethod(o))   // EF Core throws: not translatable
                 .OrderBy(o => o.Total)
                 .Take(50)
                 .ToList();

SomeLocalMethod is a C# method that EF Core cannot translate. The Where call appends an expression that the provider cannot lower to SQL, and at materialization you get an exception. Or worse, in a provider that silently falls back to client evaluation, you accidentally pull every row over the wire to filter in process. EF Core 11 throws by default; older code with AsEnumerable switches inserted in the middle of a chain is even harder to read.

The fix is to materialize at the boundary:

// .NET 11, C# 14
public async Task<IReadOnlyList<Order>> GetRecentOrdersAsync(
    int count, CancellationToken ct)
{
    return await _db.Orders
        .Where(o => o.At > _start)
        .OrderByDescending(o => o.At)
        .Take(count)
        .ToListAsync(ct);
}

The method now returns a concrete, materialized collection. The caller cannot accidentally append SQL. If the caller wants a different filter, they ask for it explicitly via a parameter or a new method. This is the same rationale that drives how to detect N+1 queries in EF Core 11: be explicit about where the query boundary sits.

The benchmark: streaming a million rows three ways

A real number. The setup: 1,000,000 narrow rows (a Guid Id, an int Status, a DateTime At) in SQL Server 2022. The consumer counts rows that pass a filter (Status == 1) and writes a sum of timestamps. We do it three ways:

// .NET 11, C# 14, EF Core 11.0.0, BenchmarkDotNet 0.14.0
[MemoryDiagnoser]
public class SequenceShapes
{
    private AppDb _db = null!;

    [GlobalSetup] public void Setup() => _db = AppDb.Connect();

    [Benchmark]
    public long Materialize_Then_Enumerate()
    {
        var rows = _db.Events.ToList();              // pull all 1,000,000
        long sum = 0; long count = 0;
        foreach (var r in rows)
            if (r.Status == 1) { sum += r.At.Ticks; count++; }
        return sum + count;
    }

    [Benchmark]
    public async Task<long> StreamAsync()
    {
        long sum = 0; long count = 0;
        await foreach (var r in _db.Events.AsAsyncEnumerable())
            if (r.Status == 1) { sum += r.At.Ticks; count++; }
        return sum + count;
    }

    [Benchmark(Baseline = true)]
    public async Task<long> Queryable_Aggregate()
    {
        var count = await _db.Events.Where(e => e.Status == 1).CountAsync();
        var sum   = await _db.Events.Where(e => e.Status == 1)
                                    .SumAsync(e => (long)e.At.Ticks);
        return sum + count;
    }
}

Methodology: BenchmarkDotNet 0.14.0, .NET 11.0.0 RTM, EF Core 11.0.0, SQL Server 2022 16.0.4135 on the same machine over loopback. Windows 11 24H2, AMD Ryzen 9 7900X, 64 GB DDR5. Numbers are a single representative run.

MethodMeanAllocated
Queryable_Aggregate (baseline)38 ms1.4 KB
StreamAsync1,210 ms410 MB
Materialize_Then_Enumerate1,380 ms432 MB

The pattern is consistent with how the three interfaces work. IQueryable<T> lets the database do the count and the sum and ship two scalars back. IAsyncEnumerable<T> saves you about 12 percent of wall time over ToList-then-loop, and it saves the spike-shaped memory profile (the List<Event> allocation in Materialize_Then_Enumerate is visible in dotnet-counters as a single gen2 spike). But both lose to the queryable form by 30x because the work belonged on the database, not in the client.

The takeaway is not “always use IQueryable”. It is: if the operation can be expressed in the provider’s query language, do not pull the rows out. If you must pull rows out (CSV export, transformation that does not translate, downstream service that wants individual items), pick IAsyncEnumerable<T> over a materialized IEnumerable<T>.

The gotchas that pick for you

A few things make the decision for you regardless of preference.

The opinionated recommendation, restated

Default to IEnumerable<T> for in-memory work. Reach for IAsyncEnumerable<T> the moment the producer needs to await, and plumb [EnumeratorCancellation] from day one. Keep IQueryable<T> inside the repository or query-builder layer; convert to a materialized IReadOnlyList<T> or to IAsyncEnumerable<T> before crossing a service boundary.

Two corollaries worth committing to muscle memory:

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back