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 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 byFirst(),Count()followed byforeach, 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.