Skip to main content

Command Palette

Search for a command to run...

Choosing Between IEnumerable<T> and IReadOnlyCollection<T> in GET endpoints

Published
4 min read
Choosing Between IEnumerable<T> and IReadOnlyCollection<T> in GET endpoints

I recently caught myself mindlessly implementing a GET API endpoint that returned an IEnumerable<T>. I've also seen this pattern repeated in many API implementations across different services. While it works, I want to walk through why using IReadOnlyCollection<T> is often a better choice specifically for GET endpoints, and at the same time clarify the scenarios where IEnumerable<T> is not just acceptable but actually preferable.

Consider the following method, which returns a list of strings. At first glance, nothing seems wrong with this implementation:

public IEnumerable<string> GetListOfString()
{
    return _someService.GetList(); // Could be a LINQ query
}

The issue becomes more visible when we look at how a consumer typically interacts with this method:

var originsResult = GetListOfString();
var count = originsResult.Count();        // Executes query
var list = originsResult.ToList();        // Executes query AGAIN!

The consumer will unknowingly enumerate the sequence multiple times. If the underlying implementation is hitting a database, the cost becomes even clearer. That’s two queries. Two DB connections. Twice the load. Twice the latency.

originsResult.Count();   // SELECT COUNT(*) ...
originsResult.ToList();  // SELECT * ... (AGAIN!)

Because IEnumerable<T> supports deferred execution, every enumeration can trigger the query again. This means a caller who simply wants the count and a materialized list ends up performing two full enumerations, which translates into unnecessary database roundtrips and repeated work. Nothing in the method’s signature tells the consumer that “multiple enumerations are expensive,” so the API contract unintentionally allows inefficiency.

Now, contrast this with returning an IReadOnlyCollection<T>:

public async Task<IReadOnlyCollection<string>> GetListOfString()
{
    var results = await _someService.GetList();
    return results.ToList(); // Materialized once
}

When consumers interact with this version, the behavior is predictable:

var originsResult = await GetListOfString();
var count = originsResult.Count;          // O(1), no re-execution
var list = originsResult.ToList();        // Simple in-memory copy

By returning IReadOnlyCollection<T>, the method communicates that the data has already been fully materialized. This avoids accidental multiple enumerations and guarantees that expensive operations such as database queries only occur once. Accessing the Count property becomes a constant-time operation rather than causing a full traversal of the sequence. This also leads to more predictable resource usage, since the work is performed upfront at the service boundary rather than deferred into serialization or consumer operations.

From an API design standpoint, IReadOnlyCollection<T> makes the contract clearer: the caller is receiving a stable, pre-computed, read-only set of results. There's no ambiguity about whether the underlying sequence might re-execute or change during enumeration. For GET endpoints that simply fetch data, this usually aligns with the intent of the operation.

However, this does not mean IEnumerable<T> is undesirable. It has a specific and extremely valuable place, especially when dealing with LINQ and query composition. Consider a method that returns an IEnumerable<T> representing a database query:

public IEnumerable<string> GetListOfString()
{
    return dbContext.ModelABC
        .Where(a => a.Property1 == "abc")
        .SelectMany(a => a.Property2); // Not executed yet
}

A consumer can build additional operations on top:

var origins = service.GetListOfString()
    .Where(o => o.Contains("123"))
    .Select(o => o.ToLower())
    .ToList(); // Executes as a single optimized query

Here, deferred execution becomes a significant advantage. Instead of prematurely loading all data, the caller can extend the query, and EF Core will compose the entire chain into a single SQL statement. This minimizes data returned from the database, avoids unnecessary memory usage, and allows the database to perform filtering and projections before the application receives the results.

If the service had instead returned IReadOnlyCollection<T>, all data would have been materialized immediately:

public async Task<IReadOnlyCollection<string>> GetListOfString()
{
    var results = await dbContext.Applications
        .Where(a => a.Property1 == "abc")
        .SelectMany(a => a.Property2)
        .ToListAsync(); // Executes immediately

    return results;
}

Now, the consumer has no ability to refine the query. The service has already fetched everything, even if the caller only needs a subset. This leads to over-fetching, unnecessary data transfer, and sometimes the need for multiple specialized service methods to compensate for the lost flexibility. In this way, IReadOnlyCollection<T> can become a disadvantage when the consumer genuinely needs to extend or customize the query.

In summary, the distinction is not about one type being “better” than the other overall, but about choosing the right tool for the specific scenario. For API GET endpoints where the intent is to return fully computed data, IReadOnlyCollection<T> offers predictability, safety, and performance benefits by ensuring exactly one materialization and preventing accidental re-execution. On the other hand, IEnumerable<T> is essential when working with deferred execution and query composition, particularly in database scenarios where combining filters into a single SQL query provides major efficiency gains. Understanding the semantics behind each return type helps ensure that the API contract accurately represents the behavior of the underlying logic and that consumers interact with the data in the most efficient and predictable way.

As always, happy reading.