Katabench
Start free
← All posts

The LINQ query that ran twice: multiple enumeration and other deferred-execution traps

There's a C# bug that produces no exception, no wrong answer, and no red test. The code does exactly what you asked. The problem is that you asked twice without noticing, and what you asked for was expensive.

It starts with a misconception so natural that most of us carried it for years: that an IEnumerable<T> is a collection. It isn't. It's a description of how to produce items, and the producing happens when someone enumerates it. Not before, and, crucially, not just once. Every enumeration runs the description again, from the top.

The innocent-looking double bill

Here's the shape. A method computes a filtered sequence, then uses it "twice":

public OrderReport BuildReport(IEnumerable<Order> allOrders)
{
    var overdue = allOrders
        .Where(o => o.DueDate < DateTime.UtcNow && !o.IsPaid)
        .Select(o => EnrichWithCustomer(o));   // calls a service per order

    if (!overdue.Any())                        // enumeration #1
        return OrderReport.Empty;

    return new OrderReport(
        Count: overdue.Count(),                // enumeration #2
        Items: overdue.ToList());              // enumeration #3
}

Nothing here is wrong in the "returns the wrong value" sense. But overdue is not a computed result sitting in memory. It's a recipe. Any() runs the recipe until the first match, Count() runs it end to end, and ToList() runs it end to end again. That per-order EnrichWithCustomer call, the expensive one, executes for most overdue orders twice, and for some of them three times. The report is correct. The bill is doubled, and if enrichment isn't idempotent (it logs, it increments a counter, it hits a rate-limited API), the extra runs are a correctness bug too, just one that lives outside your assertions.

Defined once, runs nothing

var overdue = allOrders.Where(...).Select(o => EnrichWithCustomer(o));

overdue.Any()

executed, stops at the first match

overdue.Count()

executed again

overdue.ToList()

executed again

With an EF Core IQueryable, each of these sweeps would be its own SQL round trip

×3 queries

The pipeline is a recipe, not a result. Every consumer runs it again from the top.

The fix costs one line: decide the moment the work should happen, do it once, and hand everything downstream a real collection.

var overdue = allOrders
    .Where(o => o.DueDate < DateTime.UtcNow && !o.IsPaid)
    .Select(o => EnrichWithCustomer(o))
    .ToList();                                 // the work happens exactly here, once

if (overdue.Count == 0)
    return OrderReport.Empty;

return new OrderReport(overdue.Count, overdue);

Modern tooling will even point at the original: the .NET analyzers ship rule CA1851, "Possible multiple enumerations of IEnumerable collection," which is worth turning on and treating as a real warning rather than review noise.

With EF Core, "run it again" means another database query

Everything above gets an order of magnitude more expensive the moment the sequence is a database query. An IQueryable<T> is deferred the same way, but its recipe is SQL, so every extra enumeration is a round trip:

var expensive = db.Orders.Where(o => o.Total > 10_000);

var count = await expensive.CountAsync();   // SELECT COUNT(*) ... query #1
var items = await expensive.ToListAsync();  // SELECT ...          query #2

Two queries, and between them the data can change: an order inserted after the COUNT but before the SELECT gives you a count of 41 and a list of 42, an inconsistency you'll chase for an afternoon before you believe it. If you need both, fetch the list once and count it in memory.

There's an even quieter version. A sequence with a non-deterministic step doesn't just repeat its cost on re-enumeration; it repeats with different results:

var sample = customers.OrderBy(_ => Guid.NewGuid()).Take(5);

SendPromoEmail(sample);      // five random customers
LogPromoRecipients(sample);  // five DIFFERENT random customers

The log now disagrees with reality, and nothing anywhere threw. The recipe was "pick five at random," and you ran the recipe twice.

Deferred execution is the feature; the trap is not knowing where the line is

None of this means "sprinkle ToList() everywhere," which is the overcorrection that trades double execution for materializing half your database into memory. Deferred execution is what lets EF compose your Where, OrderBy, and Take into one efficient SQL statement, and what lets LINQ-to-Objects stream a large file without loading it. You want laziness while the query is being built.

The discipline is knowing where building ends:

  • Compose lazily, materialize once, at the boundary. The moment a sequence will be consumed more than once, or leaves the method, it should usually become a List (or array) at a single, deliberate point.
  • Treat an IEnumerable<T> you receive as single-use. You don't know what's behind it: a list, a database, a network stream, a random generator. Enumerate it once, or materialize it first.
  • Watch the innocent pairs. Any() followed by First(), Count() followed by foreach, passing the same sequence to two methods. Each pair reads naturally and runs the pipeline twice.

Seeing the second query with your own eyes

The reason this trap survives in so many codebases is that both executions are invisible in the C#. overdue.Count() and overdue.ToList() look like cheap property-ish calls, and nothing on the page hints that each one restarts a pipeline. You catch it by knowing what the code does, not what it says, and that knowledge comes fastest when something shows you.

That's one of the quiet lessons built into Katabench's database track: the grader runs your LINQ against a real PostgreSQL database and lists every query your code fired. Write the CountAsync-then-ToListAsync shape and there they are, two queries where one belongs, in the same panel that catches your N+1s. The count is the feedback; you can't argue with it.

Once you've seen your own tidy method fire the same query twice, IEnumerable stops reading as "a collection" and starts reading as "a promise someone has to keep, every time it's asked." If you want that reflex, and the query log that builds it, the database track is on Pro, and here's how the grading pipeline works under the hood.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.