LINQ Performance: When It's Slow and What to Do About It

LINQ is my favorite feature of C#. It turns intent-obscuring loops into declarative one-liners, and in 95% of code its overhead is irrelevant. But I've also profiled enough .NET services to know the other 5% — the places where innocent-looking LINQ chains quietly dominate a hot path.

This post is about recognizing those places, with the fixes I actually apply. No cargo-cult "LINQ is slow, always use for loops" advice — that's wrong, and unreadable code is its own performance problem (it slows down the developers).

First, understand deferred execution

A LINQ query over IEnumerable<T> doesn't do anything when you define it:

csharp
var expensive = orders.Where(o => SlowValidation(o)); // nothing runs yet

The work happens when you enumerate — in a foreach, a ToList(), a Count(). This is the root of both the best and worst LINQ behaviors, so every pitfall below comes back to it.

Pitfall 1: Multiple enumeration

csharp
var valid = orders.Where(o => SlowValidation(o));

if (valid.Any())                    // runs validation until first match
{
    Console.WriteLine(valid.Count()); // runs ALL validations again
    Process(valid.First());           // and again, until first match
}

Three enumerations, three executions of the pipeline. If the source is a database query, that's three round trips; if the predicate is expensive, you're paying it repeatedly. ReSharper and modern analyzers warn about this ("possible multiple enumeration") — take the warning seriously.

The fix is to materialize once when you know you'll consume the results more than once:

csharp
var valid = orders.Where(o => SlowValidation(o)).ToList();

The judgment call: materializing costs one allocation of the whole result set. Enumerate-once pipelines (a single foreach over a filtered stream) should not be materialized. Consume-many pipelines should.

Pitfall 2: Count() when you mean Any()

csharp
// ❌ Walks the entire sequence
if (customers.Count(c => c.IsActive) > 0)

// ✅ Stops at the first match
if (customers.Any(c => c.IsActive))

Count() with a predicate must visit every element; Any() short-circuits. Same logic, potentially thousands of times less work. Related: prefer the Count property (lists, arrays) over the Count() method when you have a concrete collection — though LINQ is smart enough to use it under the hood for ICollection<T>.

Pitfall 3: Quadratic lookups hiding in plain sight

This is the most expensive one I find in real code:

csharp
// ❌ O(n × m): for every order, scan all customers
foreach (var order in orders)
{
    var customer = customers.FirstOrDefault(c => c.Id == order.CustomerId);
}

10,000 orders × 10,000 customers = 100 million comparisons. The fix is a dictionary built once:

csharp
// ✅ O(n + m)
var customersById = customers.ToDictionary(c => c.Id);

foreach (var order in orders)
{
    customersById.TryGetValue(order.CustomerId, out var customer);
}

I've seen this single change take a report from 40 seconds to under one. Any time you see First/FirstOrDefault/Where with an equality predicate inside a loop, reach for ToDictionary, ToLookup, or a Join.

Pitfall 4: Ordering more than you need

csharp
// ❌ Sorts everything, keeps 10
var top = products.OrderByDescending(p => p.Sales).Take(10).ToList();

Actually — this one is fine now. Since .NET 7, OrderBy(...).Take(k) is internally optimized into a partial sort. But two ordering mistakes still cost real money:

  • Sorting before filtering. OrderBy(...).Where(...) sorts elements you're about to discard; filter first.
  • OrderBy(...).First() — replace with MinBy/MaxBy (available since .NET 6), which is O(n) instead of O(n log n).

Pitfall 5: Allocation pressure in hot paths

Every lambda that captures a local variable allocates a closure; every Select/Where allocates enumerator machinery; GroupBy buffers the entire source. None of this matters in a request handler that runs occasionally. All of it matters in a loop that runs 100,000 times per second.

In genuinely hot paths (measured, not guessed — use BenchmarkDotNet or the Visual Studio profiler):

csharp
// LINQ version: clean, allocating
var total = lines.Where(l => l.IsTaxable).Sum(l => l.Amount);

// Hot-path version: zero allocations
decimal total = 0;
foreach (var l in lines)
{
    if (l.IsTaxable) total += l.Amount;
}

My rule: write LINQ first, profile, then rewrite the top offenders as loops with a comment explaining why. A codebase of preemptively "optimized" loops is harder to maintain and usually no faster where it counts.

Pitfall 6: Forgetting you're talking to a database

IQueryable<T> looks like LINQ-to-objects but compiles to SQL — until something forces it into memory:

csharp
// ❌ AsEnumerable() pulls the whole table, then filters in memory
var recent = _db.Orders
    .AsEnumerable()
    .Where(o => o.CreatedAt > cutoff)
    .ToList();

Keep filters, projections, and paging on the IQueryable side so they run in the database. Anything the provider can't translate will either throw (EF Core) or silently switch to client evaluation in older stacks — know which one your ORM does.

The mental checklist

  • Will this sequence be enumerated more than once? Materialize it.
  • Any() over Count() > 0, MinBy/MaxBy over OrderBy().First().
  • Equality search inside a loop → dictionary or join.
  • Filter before you sort; page before you materialize.
  • Hot path proven by a profiler? A loop is allowed to win.
  • On IQueryable, keep the work in SQL.

LINQ isn't slow. Unexamined LINQ in the wrong place is slow — and now you know exactly where to look.

Where to go next