Because LINQ is built on yield, a query runs nothing when it is written — it is a recipe that executes only when walked, one element at a time through the whole chain. That single fact explains the pipeline, the live-view behaviour, and the multiple-enumeration trap that catches everyone. This part follows deferred execution all the way through, and shows when to freeze a query with ToList.
Part 3 showed that a single yield method runs nothing until it is iterated, and re-runs on every fresh enumeration. LINQ operators like Where and Select are themselves iterator methods, so every one of those facts is true of LINQ. This part follows the consequences, because they are the source of both LINQ's power and its most common bugs.
Writing a LINQ query builds a description of work. It does not do the work. The operators just wire up a pipeline and hand it back; nothing runs until something walks it.
var numbers = new[] { 1, 2, 3, 4, 5 };
var query = numbers.Where(n =>
{
Console.WriteLine($"checking {n}");
return n > 2;
});
Console.WriteLine("query built"); // this prints FIRST
foreach (var n in query) // only now does the checking happen
Console.WriteLine($"got {n}");The output shows the query did nothing until the foreach:
query built
checking 1
checking 2
checking 3
got 3
checking 4
got 4
checking 5
got 5This is deferred execution: the query is a recipe, and iterating it is cooking. query holds no results — it holds the instructions for producing them.
Here is the part almost everyone pictures wrong. A chain like Where().Select() does not run all of Where and then all of Select. Each element flows through the entire chain before the next element starts.
var result = new[] { 1, 2, 3 }
.Where(n => { Console.WriteLine($"where {n}"); return n % 2 == 1; })
.Select(n => { Console.WriteLine($"select {n}"); return n * 10; });
foreach (var x in result)
Console.WriteLine($"got {x}");Watch the order — it is element-by-element, not stage-by-stage:
where 1 -> passes
select 1
got 10
where 2 -> fails the filter, so Select is never called for it
where 3 -> passes
select 3
got 30Element 1 goes all the way through (where, select, consumer) before element 2 is even looked at. And 2, failing the filter, never reaches Select at all.
This streaming is why LINQ never builds intermediate lists between operators, and why it can process huge or even infinite sources without running out of memory.
Not every LINQ operator is deferred. The rule of thumb: operators that return another sequence are deferred; operators that return a single value or a concrete collection run immediately, because they have to walk the sequence to produce their answer.
| Deferred (lazy — return a query) | Immediate (run now — return a value or collection) |
|---|---|
Where, Select, SelectMany | ToList, ToArray, ToDictionary |
Take, Skip, Distinct | Count, Sum, Average, Min, Max |
OrderBy, ThenBy, GroupBy | First, Last, Single, ElementAt |
Concat, Reverse, Cast | Any, All, Contains |
The immediate ones are also the tools for forcing a query to run and capturing its results — ToList() most of all.
A subtlety worth knowing: a few deferred operators are buffering rather than streaming.
OrderBy,GroupBy,Distinct, andReverseare still deferred (they run nothing until iterated), but once they do run, they must pull in the whole source before yielding even the first result — sorting needs to see everything before it knows what comes first. So they defer, but they do not stream one element at a time.
Because a query is a recipe that re-runs every time it is iterated, using it more than once repeats all the work.
var query = numbers.Where(Expensive); // deferred — nothing has run
int count = query.Count(); // enumeration #1 — runs Expensive over everything
int first = query.First(); // enumeration #2 — runs Expensive over it AGAINEach of Count() and First() walks the query from the start, so Expensive runs twice across the whole sequence. With a costly filter this is a silent performance bug; with a source that can only be read once — a file, a network stream, a database reader — the second pass may return different results or fail outright.
A foreach twice has the same effect: two full runs of the pipeline.
Since the query re-reads its source each time it runs, changes to the source between enumerations show up. A deferred query is a live view of the data, not a snapshot.
var list = new List<int> { 1, 2, 3 };
var query = list.Where(n => n > 1);
Console.WriteLine(query.Count()); // 2 (2 and 3)
list.Add(4);
Console.WriteLine(query.Count()); // 3 (2, 3, and 4) — the query re-ran and saw the new itemSometimes this live behaviour is exactly what is wanted; often it is a surprise. Either way, it is a direct consequence of "the recipe runs again each time."
Deferred execution also means a query reads its captured variables when it runs, not when it is written. A variable changed after the query is built affects the query.
int threshold = 5;
var query = numbers.Where(n => n > threshold); // captures the variable threshold
threshold = 100; // changed BEFORE the query runs
foreach (var n in query) // the filter now uses 100, not 5
Console.WriteLine(n); // prints nothing (nothing exceeds 100)The lambda closed over the variable threshold, not the value 5. When the foreach finally executes the lambda, it reads threshold's current value — 100. This trips people who expect the query to "remember" the value at the moment it was written.
When a query needs to be read more than once, or its result should be a fixed snapshot, run it once and keep the result. ToList() (or ToArray()) does exactly that — it forces the pipeline to run one time and stores the output in a real collection:
List<int> results = numbers.Where(Expensive).ToList(); // run the pipeline ONCE
int count = results.Count; // O(1), no re-run
int first = results[0]; // no re-run
// results is a plain List — indexing, counting, re-reading are all free nowAfter ToList(), results is finished data, not a recipe. Reading it is cheap and repeatable, the captured-variable and live-view surprises are gone (the values are frozen), and a one-shot source is safely consumed a single time.
Deferred execution is not just a hazard to manage — it is why LINQ is efficient and expressive:
First, Take, and Any stop as soon as they have their answer, so the pipeline never processes more than it must:bool anyBig = numbers.Where(Expensive).Any(n => n > 100);
// Any stops at the first match — Expensive is not run over the whole sequencevar firstThree = Naturals().Where(n => n % 7 == 0).Take(3).ToList(); // 7, 14, 21.ToList()/.ToArray() first.IEnumerable<T> hands the caller a possibly-lazy recipe (and the multiple-enumeration hazard); returning IReadOnlyList<T> signals "already materialized, safe to re-read."A LINQ query is a recipe, not results. It runs only when walked, streams one element through the whole chain at a time, and re-runs every time it is iterated — reflecting current source and variable values. That is the source of its efficiency and of the multiple-enumeration trap. When results must be reused or frozen,
ToList()cooks the recipe once.
The first four parts covered IEnumerable<T> and the mechanics that make it special — cursors, yield, and deferred execution. Part 5 widens the view to the whole collection interface family: ICollection, IList, ISet, the read-only twins, and IDictionary. Instead of memorising the hierarchy, it uses a capabilities model — ask what the code needs to do, and let the answer pick the interface — which is the map for the concrete data structures that fill the rest of the series.