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 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>orIReadOnlyCollection<T>instead of the more permissiveIEnumerable<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.TryGetNonEnumeratedCountcan 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 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, 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.