Publishing Every 3 Weeks ยท ASP.NET Core ยท Design Patterns ยท Architecture ยท 20 yrs C#/.NET ยท cleancsharp.com
Data · Performance · Best Practices

๐Ÿ” LINQ Pitfalls: The Query That Ran Twice

The query didn't run when you wrote it. It ran when you looked โ€” and it ran again when you looked again. Deferred execution, multiple enumeration, and the boundary that quietly drags a whole table into memory. Most of these traps reproduce with a List<T> and no database at all.

Phil Connors wakes up at six to Sonny & Cher, and it's February 2nd again. The town doesn't remember yesterday. The groundhog doesn't remember yesterday. Whatever Phil did โ€” the piano lessons, the ice sculpture, the truck off the quarry road โ€” none of it persists. Every morning the day runs again, from the top, exactly as written.

A LINQ query is Punxsutawney. It doesn't remember being run.

Here's the endpoint that taught me that. It filters a batch of claims through an eligibility check, logs how many passed, and submits them:

var eligible = claims.Where(c => _eligibility.IsCovered(c));   // one remote call per claim

if (eligible.Any())
{
    _logger.LogInformation("Submitting {Count} claims", eligible.Count());
    foreach (var claim in eligible)
        Submit(claim);
}

Eight lines. Reads like English. Passed review. And for a batch of 400 claims it made about 800 calls to the eligibility service โ€” not 400 โ€” because eligible isn't a list of claims. It's a description of how to get one, and every line that looked at it ran the description again. Any() stopped at the first covered claim, so it cost one call. Count() ran to the end: 400. foreach ran to the end again: another 400. Eight hundred and one calls to answer a question about four hundred claims.

Nobody wrote a loop that calls the service three times. Nobody had to. The query is the loop, and it runs every time you look.


๐ŸŽฏ The Shift

From: "A query is a collection"

To: "A query is a recipe, and every enumeration cooks it again"

Most of what goes wrong with LINQ is one misunderstanding wearing six different outfits. Where, Select, OrderBy and friends don't do anything. They return an object that knows how to do something, later, when someone asks for the elements. That's deferred execution, and it is a genuinely good design โ€” it's what lets EF Core turn a chain of C# into one SQL statement, and what lets you compose queries without materializing every intermediate step. (OrderBy is the operator to watch: it defers like the rest, but once asked it buffers the entire sequence before yielding the first element. Filter before you sort.)

But it means the variable you named eligible is not eligible claims. It's a plan for finding them. Plans don't have a count. Plans don't remember what they found last time. And a plan handed across a method boundary is a plan someone else is going to run โ€” maybe twice, maybe after the thing it depended on is gone.


๐Ÿ“œ 1. A Query Is a Plan, Not a Result

The whole post fits in one snippet:

var claims = new List<Claim> { new("CLM-1", 120m), new("CLM-2", 80m) };
var large  = claims.Where(c => c.Amount > 100m);

claims.Add(new("CLM-3", 500m));

Console.WriteLine(large.Count());   // 2

One claim matched when large was defined. It reports two โ€” because the query didn't run on line two. It ran on line six, against whatever the list held then.

That's not a bug in Where. It's what Where is. The operators that actually execute are the ones that need an answer โ€” ToList, ToArray, Count, First, Any, Sum, foreach. Everything before them is a description.

The practical rule: the moment you want a snapshot, say so.

var large = claims.Where(c => c.Amount > 100m).ToList();   // ran once, right here, and it's done

Now large is a List<Claim>. It has a Count property โ€” an O(1) field read โ€” and it still has the Count() method, which on a list is also O(1). It doesn't care what happens to claims afterwards. It can be handed to three methods and enumerated three times and the predicate ran once.


๐Ÿ” 2. The Query That Ran Twice

Back to the hook. The fix is one call:

// โœ… one pass through the eligibility service
var eligible = claims.Where(c => _eligibility.IsCovered(c)).ToList();

if (eligible.Count > 0)
{
    _logger.LogInformation("Submitting {Count} claims", eligible.Count);
    foreach (var claim in eligible)
        Submit(claim);
}

Same shape. 400 calls instead of 801. And the thing that makes this trap so durable is that it's only expensive when the predicate is expensive. Against an in-memory list with a cheap lambda, enumerating three times is three microseconds and nobody ever notices. Then somebody swaps the lambda for a service call, or the list for an EF query, and the three enumerations that were always there become three round trips.

With EF Core it's exactly that: Any() is one SELECT, Count() is a second, foreach is a third. The query counter from the last post reads 3 on a code path that looks like it reads 1.

There's an analyzer for this. CA1851 โ€” "Possible multiple enumerations of IEnumerable collection" โ€” ships in the .NET SDK and is off by default. Turn it on:

# .editorconfig
[*.cs]
dotnet_diagnostic.CA1851.severity = warning

It will be noisy for a week. Every warning it raises is a real second enumeration; the only question is whether that enumeration is cheap today.

The deeper fix is in the signatures. A method that takes IEnumerable<T> is making a promise: I will walk this once, forward, and I don't need to know how many there are. If the method body calls Count() and then foreach, it's breaking that promise, and the caller can't see it from the outside. Take IReadOnlyList<T> or IReadOnlyCollection<T> when you need more than one pass, and the contract is honest โ€” the caller has to materialize, and they'll do it once.


๐Ÿงฑ 3. The Boundary Where the Database Falls Off

This is the one the EF Core post pointed at.

The obvious version gets caught in review:

// โŒ every row, every mapped column โ€” then filter two million objects in C#
var overdue = _context.Claims.ToList().Where(c => c.DueDate < today);

Everyone can see the ToList() in the wrong place. The version that ships is the same mistake with the ToList() hidden inside a signature:

// โŒ the type says "a sequence"; the runtime says "the whole table"
public IEnumerable<Claim> GetAll() => _context.Claims;

// caller
var overdue = _repo.GetAll().Where(c => c.DueDate < today);

That looks like a filtered query. It is not. _context.Claims is an IQueryable<Claim> โ€” a query that EF Core can translate. The moment it's returned as IEnumerable<Claim>, the compiler picks Enumerable.Where instead of Queryable.Where for the caller's filter, and Enumerable.Where runs C# over objects. So the SQL is an unfiltered SELECT [c].[Id], [c].[Amount], [c].[DueDate], โ€ฆ โ€” every mapped column of every row, with no WHERE โ€” and all of it streams into the process, and the date comparison happens in memory, one claim at a time, after the whole table has arrived.

At five rows it's indistinguishable from the right thing. At two million it's the whole problem.

Nothing throws. That's what separates this from most EF mistakes โ€” since EF Core 3, a filter the provider can't translate fails loudly at runtime. But this filter was never offered to the provider. The interface boundary took it out of EF's hands before EF ever saw it.

Two rules, and they're both about the signature:

// โœ… the filter travels with the query; the boundary returns a result
public async Task<IReadOnlyList<Claim>> GetOverdueAsync(DateOnly today, CancellationToken ct) =>
    await _context.Claims
        .Where(c => c.DueDate < today)
        .ToListAsync(ct);

IQueryable<T> never crosses a service boundary. Inside the data layer, compose it freely. At the edge, apply the filter, materialize, and return something that admits it's a result โ€” IReadOnlyList<T> says "this happened, here's what came back."

If you genuinely need C# evaluation, say so with AsEnumerable(). It's the same type conversion as the interface boundary, except it's in the query, where the next reader can see the line where the database stopped and the process started:

var flagged = _context.Claims
    .Where(c => c.Status == ClaimStatus.Submitted)   // SQL
    .AsEnumerable()                                  // โ† everything below runs in C#
    .Where(c => _rules.IsSuspicious(c))              // can't translate; wasn't meant to
    .ToList();

Note what is not available on that last line. ToListAsync is an extension on IQueryable<T>, so it disappears the moment AsEnumerable() hands you an IEnumerable<T> โ€” the boundary shows up in the API surface, not just in the plan. If you need to stay off the calling thread, materialize the SQL half with ToListAsync(ct) and filter the list afterwards, or stream it with AsAsyncEnumerable() and an await foreach.

AsEnumerable() is the same trap as the repository signature, made deliberate. Fine when the SQL half already narrowed the set. Fatal when it didn't.

The same boundary decides what columns arrive, not just which rows โ€” a Select after the ToList() pulls every column and throws most of them away. The EF Core post's ยง4 covers projection; the rule is the same one: put it before the line where the database stops.


๐Ÿงฎ 4. The O(n) That Looks Like O(1)

List<T> has a Count property. IEnumerable<T> has a Count() method. They look like the same thing with different punctuation, and they are not.

IEnumerable<Claim> pending = claims.Where(c => c.Status == ClaimStatus.Pending);

for (var i = 0; i < pending.Count(); i++)     // walks the whole sequence, every iteration
    Process(pending.ElementAt(i));            // walks it again, from the start, every iteration

Count() on a query enumerates it to the end. ElementAt(i) enumerates it to position i. Last() enumerates all of it. In a loop, that's quadratic, and it's quadratic in a way that looks like an ordinary indexed loop.

LINQ does have a fast path, and the two operators check for different things: Count() short-circuits when the source implements ICollection<T>, ElementAt when it implements IList<T>. A HashSet<T> satisfies the first and not the second. Neither is satisfied here โ€” the Where result implements neither โ€” so both walk. Note that the fast path does not save the test either. Write this loop over a Where result on a three-item List<Claim> and it is still quadratic; it is just quadratic at n=3, where nothing is slow enough to notice. The fast path only applies if you hand the loop the list itself.

Any() beats Count() > 0 on a lazy sequence, for the same reason: Any() stops at the first element; Count() has to visit them all to tell you there were more than zero. Say that unqualified, though, and it becomes the other misconception โ€” that .Any() is always right and list.Count > 0 is a mistake. On a materialized List<T> it is the reverse: .Count > 0 reads a field, while .Any() allocates an enumerator to advance it once. Use Any() when the source is lazy or remote, and the Count property when you are already holding the collection. (Any(c => c.IsFlagged) is the same early exit with the predicate folded in โ€” no Where needed.) And since .NET 6, TryGetNonEnumeratedCount(out var n) will tell you whether a count is available for free โ€” and return false rather than walk the sequence if it isn't.

SingleOrDefault is the one people get backwards. It's supposed to keep going after the first match โ€” that's the whole contract, it throws if there's a second. In memory, on a Where result, that means it walks to the end. Against EF Core it doesn't: the provider translates it to TOP(2), so on an indexed key it's a second row-lookup, not a table scan. The advice "always use FirstOrDefault, it's faster" is right for the list and mostly wrong for the database. Pick by meaning: Single when a duplicate is a bug you want to hear about, First when you've ordered the sequence and want the head of it.

Materializing once turns every one of these into what it looks like.


๐ŸงŠ 5. The Variable That Changed After the Query Was Written

Deferred execution has a second consequence that has nothing to do with performance.

var threshold = 100m;
var large = claims.Where(c => c.Amount > threshold);

threshold = 1_000m;

var count = large.Count();   // compares against 1,000

The lambda captured threshold โ€” the variable, not the value it held. When the query finally runs, it reads whatever the variable holds at that moment. The line that defined the query looks like it froze the threshold at 100. It didn't freeze anything.

The version that bites people is the loop:

var byPriority = new List<IEnumerable<Claim>>();

for (var i = 0; i < 3; i++)
    byPriority.Add(claims.Where(c => c.Priority == i));

// every query in the list compares against 3

A for loop has one i, shared across every iteration; all three lambdas captured the same one, and by the time anybody enumerates, it's 3. (foreach was fixed in C# 5 to give each iteration its own variable. for was not, and won't be.)

Two fixes: copy the value into a local that nothing else touches, or materialize the query while the value is still right.

for (var i = 0; i < 3; i++)
{
    var priority = i;                                              // one variable per iteration
    byPriority.Add(claims.Where(c => c.Priority == priority).ToList());
}

โณ 6. The Exception That Arrives Late

yield return turns a method into a query. Which means the method body โ€” all of it, including the first line โ€” doesn't run when you call it.

public IEnumerable<Claim> Load(string path)
{
    if (!File.Exists(path))
        throw new FileNotFoundException($"Claims file not found: {path}", path);   // runs on the first MoveNext(), not on the call

    foreach (var line in File.ReadLines(path))
        yield return Parse(line);
}

Call Load("missing.csv") and nothing happens. Pass the result three layers up, hand it to a foreach in a controller, and that's where the FileNotFoundException surfaces โ€” with a stack trace whose top frame is Load+MoveNext and whose caller is the foreach that happened to ask โ€” the method that should have thrown, at the moment you called it, appears nowhere. Catching at the boundary โ€” the exception-handling post's whole argument โ€” only helps when the boundary can tell where the throw came from. Here it can't.

The fix is the standard split. Validate eagerly, iterate lazily:

public IEnumerable<Claim> Load(string path)
{
    if (!File.Exists(path))
        throw new FileNotFoundException($"Claims file not found: {path}", path);   // โœ… now it runs when you call it
    return LoadCore(path);
}

private IEnumerable<Claim> LoadCore(string path)
{
    foreach (var line in File.ReadLines(path))
        yield return Parse(line);
}

And the version of this that ends up in a production incident is disposal:

// โŒ returns an unexecuted query, then disposes the thing it needs
public IEnumerable<Claim> GetSubmitted()
{
    using var db = _factory.CreateDbContext();
    return db.Claims.Where(c => c.Status == ClaimStatus.Submitted);
}

The method returns a plan. Then using disposes the context. Then the caller enumerates the plan and gets ObjectDisposedException: Cannot access a disposed context instance โ€” from a line that never mentioned a context. Same root cause as every section above: the query ran when somebody looked, and by then the world had moved on.

.ToList() inside the using, and the method returns a result instead of a promise.


๐ŸŽ“ Why These Patterns Matter

Every section here is the same fact. A query is a plan. It runs when enumerated, against the world as it is then, and it runs again every time. Once that's in your head, none of these are surprising; until it is, every one of them looks like the runtime being unreasonable.

Team A treats IEnumerable<T> as "a collection, basically." Their repositories return it because it's the most general interface. Their methods call Any() and then foreach because it reads well. Everything works, and every one of these traps is armed โ€” waiting for a predicate to get expensive, a list to become a database, or a context to be disposed one line too early.

Team B has CA1851 on. Their boundaries return IReadOnlyList<T>, so a caller can't enumerate twice without noticing and can't filter in memory without meaning to. IQueryable lives inside the data layer and nowhere else. When they want a snapshot, they write ToList() and the intent is on the page.

Same LINQ. One team's queries run once, where they meant them to. The other team's run whenever somebody looks.


๐Ÿงญ Key Takeaways

  • A query is a plan, not a result โ€” Where, Select, OrderBy do nothing until ToList, Count, First, Any, or foreach asks
  • Every enumeration runs the whole thing again โ€” Any() + Count() + foreach is three executions; turn on CA1851
  • IQueryable<T> never crosses a service boundary โ€” return IReadOnlyList<T> with the filter already applied, or the database falls off and the table arrives
  • AsEnumerable() is the boundary made deliberate โ€” fine after the SQL half narrowed the set, fatal before
  • Count() walks; .Count reads โ€” ElementAt, Last and Count() in a loop are quadratic on anything that isn't already a list
  • Single vs First is about meaning, not speed โ€” EF translates Single to TOP(2); in memory it walks to the end, by design
  • Lambdas capture variables, not values โ€” copy into a local before deferring, and never trust a for variable inside a query
  • yield defers the whole method, validation included โ€” split into an eager wrapper and a private iterator
  • Never return an unexecuted query from inside a using โ€” materialize before the thing it needs is disposed

๐Ÿš€ Next Steps

  1. Enable CA1851 as a warning today and read every hit. Each one is a real second enumeration; decide whether it's cheap on purpose.
  2. Find every method returning IEnumerable<T> that's backed by a DbSet. Change the return type to IReadOnlyList<T> and move the filter inside.
  3. Search for Count() > and Count() ==. Most of them are Any() or a .Count property wearing a disguise.
  4. Find every yield return method with a guard clause at the top. Split it.
  5. Grep for for (var i where the body contains a lambda, a Task.Run, or an event subscription. Confirm each one copies the index into a local before capturing it โ€” this is the trap that returns wrong answers rather than slow ones.
  6. Grep for using in a method that returns IEnumerable. If the query leaves the block unexecuted, it's a disposed-context exception waiting for a caller.

Start with the endpoint that calls an external service inside a Where. That's where the second enumeration costs real money.

Phil only got to February 3rd once the day stopped re-running. Materialize once. Then it's tomorrow.


Related Posts:

In the next post: The Rules File โ€” governing AI before it writes a line. CLAUDE.md, copilot-instructions.md, .cursorrules, and the thesis that a rules file is where your postmortems go to live: every rule in it encodes a failure that already happened.

// KEEP READING