LINQ Performance: Multiple Enumeration, Materializing and Measuring with BenchmarkDotNet
How many times the same query runs without you noticing, when ToList saves you and when it hurts, Any() instead of Count() > 0 and TryGetNonEnumeratedCount; measuring with BenchmarkDotNet 0.15.8 why a Where inside a loop is 50x slower than a lookup, and comparing loops with LINQ on a noisy machine, with real numbers.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
Here's a query that filters the books priced above 40 in a four-book list. We put a counter inside the predicate. First we check whether there are any results, if so we print how many, and then we list the titles. What do you think calls is on the last line?
var books = new List<Book>
{
new("978-0132350884", "Clean Code", 37.90m, 12),
new("978-0135957059", "The Pragmatic Programmer", 42.50m, 0),
new("978-0321125217", "Domain-Driven Design", 54.00m, 3),
new("978-0201633610", "Design Patterns", 49.90m, 7),
};
int calls = 0;
IEnumerable<Book> expensive = books.Where(b =>
{
calls++;
return b.Price > 40m;
});
if (expensive.Any())
Console.WriteLine($"Count: {expensive.Count()}");
foreach (Book book in expensive)
Console.WriteLine(book.Title);
Console.WriteLine($"Predicate calls: {calls}");
public record Book(string Isbn, string Title, decimal Price, int Stock);
The Answer: The Same Query Ran Three Times
Count: 3
The Pragmatic Programmer
Domain-Driven Design
Design Patterns
Predicate calls: 10
For four books, the predicate was called ten times. expensive isn't a list, it's a query; we used it in three places and it ran from scratch in all three. Any() looked at two books until it found the first match. Count() looked at all four. foreach looked at all four again. 2 + 4 + 4 = 10.
That's exactly what this post is about: LINQ itself is rarely slow, but the way you use it can easily become expensive. First we'll make those costs visible, then instead of trusting our gut we'll measure with BenchmarkDotNet.
Multiple Enumeration
Ten predicate calls for four books in memory don't matter. Things change when the source is a database query, an HTTP call or a file:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
IEnumerable<Order> orders = LoadOrders();
Console.WriteLine($"Orders: {orders.Count()}");
Console.WriteLine($"Revenue: {orders.Sum(o => o.Total)}");
Console.WriteLine($"Largest: {orders.Max(o => o.Total)}");
Console.WriteLine("--- materialized ---");
List<Order> loaded = LoadOrders().ToList();
Console.WriteLine($"Orders: {loaded.Count}");
Console.WriteLine($"Revenue: {loaded.Sum(o => o.Total)}");
Console.WriteLine($"Largest: {loaded.Max(o => o.Total)}");
// A source that behaves as if it reads from a database
static IEnumerable<Order> LoadOrders()
{
Console.WriteLine(" (query executed)");
yield return new Order(1, 7, 80.40m);
yield return new Order(2, 3, 37.90m);
yield return new Order(3, 7, 54.00m);
}
public record Order(int Id, int CustomerId, decimal Total);
(query executed)
Orders: 3
(query executed)
Revenue: 172.30
(query executed)
Largest: 80.40
--- materialized ---
(query executed)
Orders: 3
Revenue: 172.30
Largest: 80.40
In the first part (query executed) was printed three times: the source was read from scratch three times, for the count, the sum and the largest value. In a real application that means the same SQL query going to the database three times. Worse, the data can change between two reads, and the count and the sum become snapshots of two different moments that don't agree. In the second part we materialized the result once with ToList(); the query ran once and everything else was done on the list in memory.
Think of a recipe. IEnumerable<T> is the recipe itself; every time you read it, you cook the dish again. ToList() is cooking it once and putting it on a plate. If you don't want to cook separately for each of your guests, use the plate.
This mistake is hard to catch by eye, because the code looks completely innocent. Make a habit of asking yourself: is this variable a result or a query? If its type is IEnumerable<T> and it's used on more than one line, check whether each use reruns the source.
Materializing: When to ToList
After all this it may be tempting to put ToList() at the end of every query. Don't. Materializing isn't free: it pulls the whole result into memory at once and gives up everything deferred execution provides: stopping early, streaming, seeing the latest state of the source.
The practical rule is this. Materialize if you'll use the result more than once, if the source is expensive, or if you need a fixed copy of the state at a particular moment. Don't if you'll walk the result once with a single foreach, if you're only answering a question that stops early like Any() or First(), or if the data is too big to fit in memory. Decide when returning a result from a method too: returning IEnumerable<T> says "this may be lazy, use it carefully"; returning IReadOnlyList<T> says "this is ready, iterate as much as you like".
A pattern I often see in real projects is ToList() calls sprinkled through the middle of a chain: orders.ToList().Where(...).ToList().Select(...).ToList(). Each one allocates an intermediate list, and not one of them makes the code more correct. Pick a single place to materialize: the place where the result really gets reused.
Count(), Any() and TryGetNonEnumeratedCount
Asking whether a sequence is empty with Count() > 0 means paying the cost of counting and throwing most of the result away:
var books = new List<Book>
{
new("978-0132350884", "Clean Code", 37.90m, 12),
new("978-0135957059", "The Pragmatic Programmer", 42.50m, 0),
new("978-0321125217", "Domain-Driven Design", 54.00m, 3),
new("978-0201633610", "Design Patterns", 49.90m, 7),
};
int calls = 0;
IEnumerable<Book> cheap = books.Where(b =>
{
calls++;
return b.Price < 50m;
});
Console.WriteLine($"Count() > 0: {cheap.Count() > 0}, calls: {calls}");
calls = 0;
Console.WriteLine($"Any(): {cheap.Any()}, calls: {calls}");
Report("List<Book>", books);
Report("Where(...)", cheap);
Report("Select(...)", books.Select(b => b.Title));
Report("Repeat(...)", Enumerable.Repeat("Clean Code", 3));
static void Report<T>(string name, IEnumerable<T> source)
{
bool cheapCount = source.TryGetNonEnumeratedCount(out int count);
Console.WriteLine($"{name,-12} known: {cheapCount,-5} count: {count}");
}
public record Book(string Isbn, string Title, decimal Price, int Stock);
Count() > 0: True, calls: 4
Any(): True, calls: 1
List<Book> known: True count: 4
Where(...) known: False count: 0
Select(...) known: True count: 4
Repeat(...) known: True count: 3
Count() > 0 ran the predicate four times; Any() stopped at the first match and ran it once. Always use Any() for emptiness checks.
Count() itself is smart: if the source is an ICollection<T>, say a List<T> or an array, it reads the Count property without counting. If the source is a Where chain it can't do that and walks to the end. TryGetNonEnumeratedCount, which came with .NET 6, shows you this difference explicitly: it returns true if the count is known without enumerating, and false without even trying to enumerate if it isn't. On the list and on Repeat, the count is known. When Select sits on top of a list, it doesn't change the number of elements, so the count can still be known. With Where, there's no way to know how many elements will pass. This method is useful where you don't want to walk a source just to learn its size, for example when pre-allocating capacity for a list. These outputs come from the .NET 8.0.25 runtime; which sources "know" their count is an implementation detail and may grow from version to version.
Measuring with BenchmarkDotNet
The only honest way to talk about performance is to measure. Timing a single run with Stopwatch misleads you: JIT warm-up, the garbage collector, CPU frequency changes and other work running on the machine make a single measurement meaningless. BenchmarkDotNet handles all of that for you: it warms the code up, runs it many times, computes statistics and tells you how reliable the results are.
Set up the measurement project as a separate console project, add the package with dotnet add package BenchmarkDotNet and always run it in Release mode: dotnet run -c Release. In Debug mode optimizations are off, and what you measure is the speed of the debug build, not of the real code. I used BenchmarkDotNet 0.15.8 for the measurements in this post. Like the rest of the series, the project targets net6.0; some of the package's dependencies warned during the build that they "don't support net6.0", and since the machine has no .NET 6 runtime, the measurements ran on .NET 8.0.25 through roll-forward. In your own project, target the measurement project at the runtime your application will run on.
When measuring LINQ, the first trap is deferred execution itself:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<DeferredBenchmarks>();
public record Book(string Isbn, string Title, decimal Price, int Stock);
[MemoryDiagnoser]
public class DeferredBenchmarks
{
private List<Book> _books = null!;
[GlobalSetup]
public void Setup() => _books = Enumerable.Range(1, 10_000)
.Select(i => new Book($"978-{i:D9}", $"Book {i}", 10m + i % 70, i % 20))
.ToList();
// Returns the query but never runs it
[Benchmark]
public IEnumerable<Book> ReturnsQuery() => _books.Where(b => b.Price > 40m);
[Benchmark]
public List<Book> ReturnsList() => _books.Where(b => b.Price > 40m).ToList();
}
// Validating benchmarks:
// Benchmark DeferredBenchmarks.ReturnsQuery: DefaultJob
// * Benchmark DeferredBenchmarks.ReturnsQuery returns a deferred execution result (IEnumerable<Book>). You need to either change the method declaration to return a materialized result or consume it on your own. You can use .Consume() extension method to do that.
ReturnsQuery describes the query and returns it, but never runs it. Had we measured it, we'd have seen a completely meaningless number of a few nanoseconds. BenchmarkDotNet noticed and stopped before measuring anything. A benchmark method should either return a materialized result, like ReturnsList, or consume the result itself; the Consume() extension mentioned in the error message exists for that.
Measurement: Where Inside a Loop
The LINQ mistake that wastes the most time in real projects is filtering an entire collection over and over inside a loop. We wrote three ways of finding and summing each customer's orders among 20,000 orders, for each of 500 customers. The same program has a second class comparing a simple aggregation done with a loop and with LINQ:
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run(new[] { typeof(LookupBenchmarks), typeof(LoopBenchmarks) });
public record Order(int Id, int CustomerId, decimal Total);
public record Book(string Isbn, string Title, decimal Price, int Stock);
public static class TestData
{
public static List<Order> Orders(int count, int customers)
{
var random = new Random(42);
return Enumerable.Range(1, count)
.Select(i => new Order(i, random.Next(1, customers + 1), random.Next(10, 200)))
.ToList();
}
public static List<Book> Books(int count)
{
var random = new Random(42);
return Enumerable.Range(1, count)
.Select(i => new Book($"978-{i:D9}", $"Book {i}", random.Next(10, 80), random.Next(0, 20)))
.ToList();
}
}
[MemoryDiagnoser]
public class LookupBenchmarks
{
private List<Order> _orders = null!;
private int[] _customerIds = null!;
[GlobalSetup]
public void Setup()
{
_orders = TestData.Orders(20_000, 500);
_customerIds = Enumerable.Range(1, 500).ToArray();
}
[Benchmark(Baseline = true)]
public decimal WhereInsideLoop()
{
decimal grandTotal = 0;
foreach (int id in _customerIds)
grandTotal += _orders.Where(o => o.CustomerId == id).Sum(o => o.Total);
return grandTotal;
}
[Benchmark]
public decimal ToLookupOnce()
{
ILookup<int, Order> byCustomer = _orders.ToLookup(o => o.CustomerId);
decimal grandTotal = 0;
foreach (int id in _customerIds)
grandTotal += byCustomer[id].Sum(o => o.Total);
return grandTotal;
}
[Benchmark]
public decimal ToDictionaryOnce()
{
Dictionary<int, decimal> totals = _orders
.GroupBy(o => o.CustomerId)
.ToDictionary(g => g.Key, g => g.Sum(o => o.Total));
decimal grandTotal = 0;
foreach (int id in _customerIds)
if (totals.TryGetValue(id, out decimal total))
grandTotal += total;
return grandTotal;
}
}
[MemoryDiagnoser]
public class LoopBenchmarks
{
private List<Book> _books = null!;
[GlobalSetup]
public void Setup() => _books = TestData.Books(10_000);
[Benchmark(Baseline = true)]
public int ForLoop()
{
int stock = 0;
for (int i = 0; i < _books.Count; i++)
if (_books[i].Price > 40m)
stock += _books[i].Stock;
return stock;
}
[Benchmark]
public int ForeachLoop()
{
int stock = 0;
foreach (Book book in _books)
if (book.Price > 40m)
stock += book.Stock;
return stock;
}
[Benchmark]
public int Linq() => _books.Where(b => b.Price > 40m).Sum(b => b.Stock);
}
BenchmarkDotNet v0.15.8, macOS 27.0.1 (26A434) [Darwin 27.0.0]
Apple M5, 1 CPU, 10 logical and 10 physical cores
.NET SDK 10.0.402
[Host] : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
DefaultJob : .NET 8.0.25 (8.0.25, 8.0.2526.11203), Arm64 RyuJIT armv8.0-a
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio |
|----------------- |------------:|----------:|----------:|------:|--------:|--------:|--------:|----------:|------------:|
| WhereInsideLoop | 19,114.4 us | 370.92 us | 427.15 us | 1.00 | 0.03 | - | - | 78.13 KB | 1.00 |
| ToLookupOnce | 369.6 us | 1.46 us | 1.36 us | 0.02 | 0.00 | 73.7305 | 25.8789 | 603.27 KB | 7.72 |
| ToDictionaryOnce | 378.7 us | 7.19 us | 7.39 us | 0.02 | 0.00 | 81.0547 | 27.8320 | 663.61 KB | 8.49 |
WhereInsideLoop looks at all 20,000 orders for every customer: 500 × 20,000, that is, ten million predicate calls. ToLookupOnce walks the orders once to build a lookup by customer, then goes straight to each customer's own orders. The result is about a 50x difference: 19.1 ms versus 0.37 ms. ToDictionaryOnce is in the same range. This isn't a micro-optimization; it's reducing the algorithm's complexity from O(n × m) to O(n + m).
You see the price in the memory column: the lookup and the dictionary allocated about 8 times more memory for intermediate structures and gave the garbage collector work in the Gen0/Gen1 columns. In this case it's worth it; you gain 18 ms in exchange for a 600 KB intermediate structure. But in every measurement look at both columns: time and memory. One can get better while the other gets worse.
Measurement: Loop or LINQ
The second class computes the total stock of books priced above 40 among 10,000 books in three ways. I ran the same benchmark twice. The first run:
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio |
|------------ |---------:|---------:|---------:|---------:|------:|--------:|----------:|------------:|
| ForLoop | 30.87 us | 1.301 us | 3.836 us | 30.72 us | 1.02 | 0.18 | - | NA |
| ForeachLoop | 21.77 us | 0.744 us | 2.195 us | 20.82 us | 0.72 | 0.11 | - | NA |
| Linq | 73.87 us | 0.739 us | 0.692 us | 74.02 us | 2.43 | 0.30 | 72 B | NA |
The second run:
| Method | Mean | Error | StdDev | Median | Ratio | RatioSD | Allocated | Alloc Ratio |
|------------ |----------:|---------:|---------:|----------:|------:|--------:|----------:|------------:|
| ForLoop | 28.32 us | 1.455 us | 4.268 us | 26.94 us | 1.02 | 0.21 | - | NA |
| ForeachLoop | 36.77 us | 0.735 us | 1.962 us | 36.72 us | 1.33 | 0.19 | - | NA |
| Linq | 103.01 us | 2.051 us | 2.362 us | 103.19 us | 3.71 | 0.51 | 72 B | NA |
Put the two tables side by side and the lesson itself appears. In the first run foreach looked faster than for; in the second, the opposite. BenchmarkDotNet raised a MultimodalDistribution warning for both loops, meaning it told us the measurements didn't cluster around a single value. The reason is this machine: other builds and benchmarks were running on the same computer during the measurement; during the second run the load average went above 14 on a 10-core processor. In an environment like that, 30 percent differences are noise. If the StdDev column is 10 to 15 percent of the mean, don't draw firm conclusions from that row.
The result that didn't change across both runs is this: the LINQ version took about 2.4 to 3.7 times as long as the loops and allocated 72 bytes; that's for the chain's iterator objects. In absolute terms it's a difference of tens of microseconds for 10,000 books. If this code is on a hot path that runs thousands of times a second, turning it into a loop may make sense. If it runs once per HTTP request, it's invisible next to the milliseconds a database query spends in the same request; choose the readable one. The Where inside a loop, on the other hand, is a bug that needs fixing either way.
Common Pitfalls
Measuring in Debug mode. BenchmarkDotNet notices a non-optimized build and stops before measuring anything; its message says the build is most likely Debug and that you should build in Release. There are ways to turn this check off; don't. A measurement that isn't Release isn't a measurement.
Trusting a single run on a noisy machine. The two tables above are a live example. Repeat the measurement on a quiet machine, read the warnings and only base decisions on large, repeatable differences.
Optimizing the wrong thing. The microseconds you gain by turning LINQ into a loop are small next to the milliseconds you gain by turning the Where inside a loop into a lookup. Look at the algorithm first, then at multiple enumeration, and at the operators themselves last.
Materializing inside a loop. A line like foreach (var c in customers) { var list = orders.ToList(); ... } copies all orders into a new list for every customer. If you're going to materialize, do it once, before the loop.
A Small Experiment
★☆☆ Using LoadOrders from the multiple enumeration example, compute the count, the sum and the largest value with a single foreach, reading the source once. Compared with the ToList() version, which one reads better?
★★☆ Add a [Params(10, 500, 5_000)] that changes the number of customers to LookupBenchmarks. How does the difference between WhereInsideLoop and ToLookupOnce change with the number of customers? Does the lookup still win with 10 customers?
★★★ Add a foreach version to LoopBenchmarks that takes the books as an IEnumerable<Book>. Does it give a different result from foreach over a List<Book>? Look into the difference between a struct enumerator and a call through an interface.
In the next post we take LINQ out of sample lists and onto real data: validation, reporting and writing back to a file, with orders read from a JSON file.