Skip to content
Katabench
Try free
7 min read The Katabench team Updated

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

An IEnumerable isn't a list; it's a promise to do work later. Enumerate it twice and the work happens twice, including, with EF Core, the database query.

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. The CA1851 documentation also explains the important boundary: casts are safe only when you know the runtime sequence is an array or collection. An arbitrary IEnumerable<T> may be lazy, expensive, or single-use.

Read IEnumerable<T> as "a way to request the next item," not "a bag of items already in memory."

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 the entire result and it is safely bounded, fetch the list once and count it in memory. If you are building a paginated endpoint over a large result, materializing every row just to avoid a second query is worse. Run the count and page queries deliberately, accept their consistency semantics, or use a transaction when an exact snapshot is a real requirement.

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.

Not every second pass is a bug

Arrays, lists, and other in-memory collections are designed for repeated traversal. Enumerating a List<T> twice does not replay a database call or regenerate random values. It can still double CPU work in a hot path, but it does not carry the same semantic risk as an unknown lazy sequence.

That is why the repair should follow what the source actually is:

  • If the method needs indexing, a stable count, or repeated traversal, say so in the parameter type: accept IReadOnlyList<T> or IReadOnlyCollection<T> instead of the more permissive IEnumerable<T>.
  • If the method really streams, preserve single-pass behavior. Do not materialize an unbounded file, network response, or generated sequence merely to silence an analyzer.
  • If you only need a count, Enumerable.TryGetNonEnumeratedCount can obtain it from known collection types without starting enumeration, and tell you when it cannot.
  • If two database queries answer two different questions, name and measure both. The smell is an accidental replay, not the number two itself.

You can make the contract visible with a narrow signature:

public OrderReport BuildReport(IReadOnlyList<Order> overdue)
{
    if (overdue.Count == 0)
        return OrderReport.Empty;

    return new OrderReport(overdue.Count, overdue);
}

Now the caller owns the materialization decision, and the callee cannot unknowingly restart an arbitrary pipeline. Types are often a better long-term fix than remembering not to call Count().

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, the guided LINQ exercises let you build it against hidden datasets and a visible query log. The complete 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.