A reporting method on a dashboard endpoint started showing up in the traces twice as heavy as the numbers it returned could explain. It pulled a filtered set of orders, logged how many there were, then summed them. Two cheap-sounding operations. The profiler disagreed, and the line it kept landing on was the one I'd never have circled: a Count() call I'd added for a log message.
Here's the shape of what I had.
void Report(IEnumerable<Receipt> receipts) { logger.LogInformation("Processing {Count} receipts", receipts.Count()); double total = 0; foreach (var r in receipts) total += r.Total; logger.LogInformation("Total {Total:C}", total); } Looks like it touches the data twice, which it does, but that's not the expensive part. The expensive part is where receipts came from. It wasn't a list. It was a LINQ pipeline someone handed down the call chain:
var receipts = orders .Where(o => o.Region == "EU") .Select(o => new Receipt(o.Id, double.Parse(o.RawAmount) * 1.2)); That expression doesn't contain any receipts. It's a recipe for producing them, and it produces them fresh every single time something enumerates it. Count() enumerates it once to count. The foreach enumerates it again to sum. Same recipe, run top to bottom twice, filter and parse and allocate on both passes. The log line I thought was free was re-running the entire query.
Making the query admit it
I didn't want to argue from the profiler's vibe, so I made the projection count itself. Every time the Select runs, it bumps a counter. If the pipeline runs twice, the counter can't hide it.
int projected = 0; IEnumerable<Receipt> Pipeline(IEnumerable<Order> orders) => orders .Where(o => o.Region == "EU") .Select(o => { Interlocked.Increment(ref projected); var amount = double.Parse(o.RawAmount); return new Receipt(o.Id, amount * 1.2); }); Then I ran the two-pass consumer against it and compared it to the obvious fix: enumerate once into a List, then ask the list whatever you want. Two hundred thousand source rows, a third of them in the EU bucket, so 66,667 matches. Best of seven, JIT warmed first. This is a container, not a lab bench, so I trust the projection count (it's exact) more than the millisecond.
// two passes: Count() then foreach, straight off the pipeline var count = receipts.Count(); double total = 0; foreach (var r in receipts) total += r.Total; // one pass: materialize, then count and sum the list var list = receipts.ToList(); double total = 0; foreach (var r in list) total += r.Total; Source rows: 200,000 EU rows (expected): 66,667 Results agree: True (66,667 receipts) enumerate twice projections: 133,334 best: 12.2 ms materialize once projections: 66,667 best: 7.2 ms Projection ratio: 2.00x Time ratio: 1.69x Exactly double. 133,334 projections to produce 66,667 receipts, because every receipt got built, filtered, and parsed twice and half of them thrown away the first time around. The wall clock didn't quite double, which is the honest part: parsing and allocating are most of the cost here but not all of it, so the second pass adds about two-thirds again rather than a clean 100%. The ratio you'll see depends entirely on how heavy your Select is. Mine's a double.Parse. If yours hits a database, "twice" stops being a benchmark curiosity and becomes two round trips.
The fix is one call, and so is the trap
Materializing with ToList() collapses it back to one pass. The consumer can count the list, enumerate the list, index into the list, all for free, because a List<T> is results, not a recipe. If a method is going to look at a sequence more than once, it should own a materialized copy rather than a lazy expression it doesn't control.
The caveat, and it's a real one: ToList() is also how you turn a lovely streaming, bounded-memory pipeline into a full buffer in RAM. If the sequence is huge, or infinite, or you're deliberately streaming it so you never hold all of it at once, materializing is the bug and single-pass enumeration is the feature. The honest rule I land on is about intent. If I'm going to touch a sequence exactly once, left to right, I leave it lazy. The moment a method both counts and iterates, or iterates twice, I either materialize at the top or change the signature to demand a materialized type and push the decision to the caller.
That second option is the one I actually reach for in library-ish code:
void Report(IReadOnlyCollection<Receipt> receipts) // not IEnumerable { logger.LogInformation("Processing {Count} receipts", receipts.Count); // ... } IReadOnlyCollection<T> has a Count property, no enumeration required, and the type itself tells every caller "I'm going to treat this as a finished collection." You stop guessing whether the thing you were handed is a list or a time bomb, because the parameter won't accept the time bomb.
Here's the complete runnable sample if you want to watch the counter tick. Drop it in a net10.0 console project and dotnet run -c Release.
using System.Diagnostics; Order[] source = BuildOrders(200_000); int _projected = 0; IEnumerable<Receipt> Pipeline(IEnumerable<Order> orders) => orders .Where(o => o.Region == "EU") .Select(o => { Interlocked.Increment(ref _projected); var amount = double.Parse(o.RawAmount); return new Receipt(o.Id, amount * 1.2); }); static (int count, double total) SummarizeTwice(IEnumerable<Receipt> receipts) { var count = receipts.Count(); // pass one double total = 0; foreach (var r in receipts) // pass two total += r.Total; return (count, total); } static (int count, double total) SummarizeOnce(IEnumerable<Receipt> receipts) { var list = receipts.ToList(); // the only enumeration double total = 0; foreach (var r in list) total += r.Total; return (list.Count, total); } const int Runs = 7; _ = SummarizeTwice(Pipeline(source)); _ = SummarizeOnce(Pipeline(source)); _projected = 0; var twiceProjected = 0; var bestTwice = double.MaxValue; (int count, double total) twiceResult = default; for (int i = 0; i < Runs; i++) { _projected = 0; var sw = Stopwatch.StartNew(); twiceResult = SummarizeTwice(Pipeline(source)); sw.Stop(); bestTwice = Math.Min(bestTwice, sw.Elapsed.TotalMilliseconds); twiceProjected = _projected; } var onceProjected = 0; var bestOnce = double.MaxValue; (int count, double total) onceResult = default; for (int i = 0; i < Runs; i++) { _projected = 0; var sw = Stopwatch.StartNew(); onceResult = SummarizeOnce(Pipeline(source)); sw.Stop(); bestOnce = Math.Min(bestOnce, sw.Elapsed.TotalMilliseconds); onceProjected = _projected; } var euCount = source.Count(o => o.Region == "EU"); Console.WriteLine($"Source rows: {source.Length:N0}"); Console.WriteLine($"EU rows (expected): {euCount:N0}"); Console.WriteLine($"Results agree: {twiceResult == onceResult} ({twiceResult.count:N0} receipts)"); Console.WriteLine(); Console.WriteLine($"enumerate twice projections: {twiceProjected,10:N0} best: {bestTwice,7:F1} ms"); Console.WriteLine($"materialize once projections: {onceProjected,10:N0} best: {bestOnce,7:F1} ms"); Console.WriteLine(); Console.WriteLine($"Projection ratio: {(double)twiceProjected / onceProjected:F2}x"); Console.WriteLine($"Time ratio: {bestTwice / bestOnce:F2}x"); static Order[] BuildOrders(int n) { var regions = new[] { "EU", "US", "APAC" }; var orders = new Order[n]; for (int i = 0; i < n; i++) orders[i] = new Order(i, regions[i % 3], (10 + i % 500).ToString()); return orders; } record struct Order(int Id, string Region, string RawAmount); record struct Receipt(int Id, double Total); The one that bites in production isn't even this tidy. It's an IEnumerable<T> returned from a repository, passed through three methods, and somewhere along the way one of them calls .Any() to null-check and another does the real loop, and now your query ran twice and nobody wrote the word "twice" anywhere. What's the sneakiest double-enumeration you've found in your own code? I'd like to collect a few.
— still counting things nobody asked me to count
