Building a query executes nothing
names.Where(predicate) does not filter anything. It constructs an object that implements IEnumerable<T> and remembers two things: the source sequence and the delegate. No element is read, no predicate is invoked, and no allocation proportional to the data is made. The work happens when something calls GetEnumerator and starts pulling — a foreach, a ToList, a Count, an await foreach, or passing it to a method that enumerates.
That much most people know. The consequence that catches them out is what "remembers the source" means. It is a reference to the list, not a copy of it, and the delegate closes over variables rather than over their values at the moment you wrote the lambda. So the query is a recipe, and the ingredients are looked up fresh each time you cook.
var names = new List<string> { "ann", "bob" };
var threshold = 3;
var shortNames = names.Where(n => n.Length <= threshold);
names.Add("charlie");
threshold = 7;
foreach (var n in shortNames) Console.WriteLine(n);
Trace it. At the point the query is written the list holds two items and threshold is 3, so the intuitive answer is ann and bob. But enumeration happens after both mutations, against a list of three items and a threshold of 7. ann and bob are three characters, charlie is exactly seven, and all three pass. The query you wrote would have returned two items; the query you ran returned three, including one added after the fact and one that only qualifies under a threshold changed after the fact.
Every enumeration runs the whole chain again
Because there is no stored result, each enumeration re-executes every stage. Three consequences matter in real code.
The first is cost. if (query.Any()) { var first = query.First(); } enumerates the source twice, and a chain ending in an OrderBy sorts twice. Where each stage is cheap this is invisible; where a stage calls a method that hits a network or a disk it is a duplicated round trip per enumeration.
The second is side effects. A Select whose projection writes a log line, increments a counter, or mutates the object it is projecting will do that once per enumeration. Two enumerations produce two log lines per element and an unexplained doubled metric, which is a genuinely hard bug to see from reading the code because nothing in the code says "twice".
The third is instability. Two enumerations of the same query over a source someone else is mutating can legitimately produce different results, so a method that validates a query and then returns it has validated nothing. Worse, mutating the source during enumeration throws: List<T>'s enumerator tracks a version stamp, so foreach (var n in shortNames) names.Remove(n); fails with InvalidOperationException on the iteration after the removal rather than doing something quietly wrong.
Streaming operators and buffering operators
The distinction is worth having ready, because it decides whether a query can work over an infinite or expensive sequence. Where, Select, Take, Skip and Cast are streaming: they pull one element, do their work, and yield, so memory is constant and the first result appears before the source has been fully read.
OrderBy, GroupBy, Reverse, ToList, ToDictionary and Distinct cannot behave that way. Sorting requires knowing every element before the first one can be emitted, so OrderBy reads and buffers the entire source on the first MoveNext. That is why query.OrderBy(x => x.Name).First() still materialises the whole sequence, and why chaining Take(10) after an OrderBy limits what you receive rather than what is read. Getting this right in an interview separates someone who has read the operator remarks from someone who has assumed laziness is uniform.
Count() deserves a footnote of its own: on a source that implements ICollection<T> it short-circuits to the Count property, but on a chained query it walks every element. So the same call is O(1) or O(n) depending on what it is called on.
Closures capture variables, and loops complicate that
Since C# 5, a foreach iteration variable is a fresh variable per iteration, so a lambda created inside the loop body captures that iteration's value. A for loop's counter was not changed and is still one variable shared by every iteration, so a list of deferred queries built inside a for loop all close over the same counter and all see its final value.
var queries = new List<IEnumerable<int>>();
for (int i = 0; i < 3; i++)
{
queries.Add(numbers.Where(n => n == i));
}
The fix is to copy into a local inside the loop body, which is the same discipline the deferred-execution model demands everywhere: if you want a value fixed at the point you wrote the code, you must fix it yourself.
Where to materialise
The rule that keeps this manageable is about boundaries rather than about caution. Materialise with ToList or ToArray when you cross a boundary where the source's mutability or the query's cost stops being yours to reason about: returning from a method whose caller cannot see the source, storing the result for later, enumerating more than once, or handing it to code that will iterate while you mutate. Keep the query lazy inside one method where you can see both ends of it, since that is where laziness pays for itself by not allocating intermediate collections.
The opposite failure is worth naming too. ToList in the middle of a chain allocates a full intermediate collection and defeats the streaming that made the chain cheap, so a defensive ToList after every operator turns a constant-memory pipeline into several copies of the data.
A LINQ-to-objects query is an iterator holding a reference to its source and a closure over live variables, so what you get depends entirely on when you enumerate it and how many times.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).