A LINQ query is a recipe, not a result. When you write products.Where(p => p.Price > 10), nothing is filtered yet. The chain holds onto the source, the predicate, and the steps, then runs them only when something asks for the values. This lesson covers when LINQ runs, when it doesn't, and the bugs that show up when you forget the difference.
A useful way to think about LINQ: a query expression returns a description of work, not the work's output. The description is an IEnumerable<T>, and it sits there doing nothing until something pulls values out of it.
Look at the order. The "checking" lines from the predicate don't appear until the foreach starts. The Where call returns an iterator object that captures the source and the lambda, then returns control immediately. The lambda runs once per element, but only because the foreach is pulling values through it.
This pattern is called deferred execution. The query defers all real work until a consumer asks for values. The consumer here is the foreach loop. The next section covers what triggers execution.
Three things make a deferred query run:
foreach loop over the query.GetEnumerator().MoveNext() (which is what foreach does internally).Each of those triggers walks the chain from front to back, pulling one element at a time. The same query variable produced three identical result sets, but each trigger ran the predicate again.
LINQ's operators split into two groups based on whether they trigger execution.
Deferred operators return another IEnumerable<T> and do no work until iterated. They chain freely. Terminal operators (also called immediate operators) return a concrete value, a collection, or trigger a side effect, and they run the whole pipeline once.
| Group | Operators | Returns |
|---|---|---|
| Deferred (lazy) | Where, Select, OrderBy, OrderByDescending, ThenBy, GroupBy, Take, TakeWhile, Skip, SkipWhile, Distinct, Reverse, Join, GroupJoin, SelectMany, Concat, Union, Intersect, Except, Zip | IEnumerable<T> |
| Terminal: scalar | Count, LongCount, Sum, Min, Max, Average, Aggregate, First, FirstOrDefault, Last, Single, SingleOrDefault, ElementAt, Any, All, Contains | a single value |
| Terminal: materializers | ToList, ToArray, ToDictionary, ToHashSet, ToLookup | a fully built collection |
You can mix and chain them all day, but only the terminal operators kick the recipe into motion.
Where, OrderBy, and Select produce a deferred chain. ToList is the trigger. After ToList, you have a real List<string> you can index, mutate, and pass around without surprise re-execution.
Each materializer (ToList, ToArray, ToDictionary, ToHashSet) allocates a new collection and copies every element. If you only iterate the result once, the allocation is wasted work. Use the deferred chain directly in the foreach and skip the copy.
The same query, iterated twice, runs the whole pipeline twice. This is the most common deferred-execution pitfall.
Eight calls, not four. Each foreach walked the source again and re-evaluated the predicate against every element. With a cheap in-memory predicate, that's nothing. With a query that hits a database, calls a web service, or reads a file, re-enumeration is a problem.
The fix is materializing once, then iterating the snapshot:
If you iterate the same query more than once and the chain is non-trivial (multiple operators, expensive predicate, IO-backed source), call .ToList() once and reuse the list. If you iterate exactly once, skip the copy.
Not every deferred operator behaves the same way during iteration. Some stream elements one at a time. Others buffer the whole source before they can produce the first result.
| Operator | Behavior |
|---|---|
Where | Streaming. Reads one element, decides keep or skip. |
Select | Streaming. Reads one, transforms, yields. |
Take(n) | Streaming. Yields up to n, then stops the source. |
Skip(n) | Streaming. Drops n, yields the rest. |
TakeWhile / SkipWhile | Streaming. Stop or start on a per-element decision. |
Concat, Zip | Streaming. Pull from sources in order. |
OrderBy, OrderByDescending, ThenBy | Buffering. Must read everything before yielding the first sorted element. |
Reverse | Buffering. Must reach the end before yielding. |
Distinct, Union, Intersect, Except | Buffering (uses a hash set internally). |
GroupBy, ToLookup | Buffering. Must read everything to know all group members. |
The distinction matters for memory. A Where chain over a million-row file uses a constant amount of memory because each row flows through and is discarded. The same chain ending in OrderBy has to load every row into memory before sorting.
In the streaming example, two reads were enough because Take(2) stopped pulling after the second match. In the buffering example, OrderBy had to read all four rows before it could decide which one was smallest. Same source, very different access patterns.
Buffer operators (OrderBy, Distinct, GroupBy, Reverse) allocate an internal buffer the size of the source. For huge sequences, prefer pushing filters and Take upstream of the sort, so the buffer only holds what's needed.
LINQ's deferred chain works on a pull model. The terminal end of the chain asks for the next value, which asks its upstream for the next value, and so on back to the source. Nothing is pushed; everything is pulled.
The cyan boxes are deferred operators. They wrap the source and pass requests upstream. The orange box on the right is whatever forces execution: a loop, a materializer, or an aggregate. Read the arrows in pairs: a "pull next" request goes from right to left, and a value flows from left to right. One round trip per element.
This model explains why Where + Take(1) stops at the first match: once the sink stops asking, no more values are pulled, and the source is never fully read.
If you change the underlying collection while iterating a deferred query, the iterator throws.
List<T>'s enumerator tracks a version number. When the list changes, the version moves, and the enumerator's next MoveNext notices the mismatch and throws. This is List<T>'s built-in safety net; it's the same exception you'd get from a plain foreach (var name in cart) after a mutation.
The deferred query doesn't make this worse, but it also doesn't make it better. The query holds a reference to cart itself, not a snapshot. Any change to cart between or during iterations is visible to the next foreach.
If you need to mutate while reading, take a snapshot first:
ToList materializes once, and the loop walks that copy. Mutations to cart don't disturb it.
A classic deferred-execution bug. Capture a loop variable inside a lambda, and every captured lambda sees the same variable, not separate copies.
Output (on modern C#, 5.0+):
Modern C# scopes the loop variable i to each iteration of the for loop body, so each lambda captures its own snapshot. This used to be the broken case in C# 4 and earlier with foreach, and for still has the issue today because for's control variable is shared across iterations.
Here's the actual trap with a for loop:
By the time the queries run, idx is 3, which is out of range for thresholds. Each lambda reads the same idx variable, and idx's final value is whatever the loop left it at.
The fix is a per-iteration local that the lambda captures by value:
Each lambda now captures its own threshold. The bug only shows up because the queries are deferred; if Where ran immediately, the predicate would see the appropriate value at construction time. Deferred execution and closures are independently useful and together produce this error-prone interaction.
When you need stable, predictable behavior, materialize. The four common materializers each have a job.
Each one solves a different problem. Pick based on what the consumer needs:
| Materializer | Use when |
|---|---|
ToList() | You need indexed access, want to add or remove items, or plan to iterate more than once. |
ToArray() | The size is fixed and you want a slightly smaller per-element overhead. |
ToDictionary(keySelector) | You need O(1) lookup by some key. Throws on duplicate keys. |
ToHashSet() | You need O(1) Contains or set operations, and don't care about order. |
ToLookup(keySelector) | Like a dictionary, but values are grouped. Duplicate keys are fine. |
The wrong materializer wastes memory or CPU. Calling ToList() only to later do list.Contains(x) in a loop is much slower than ToHashSet(). Calling ToDictionary when you just need indexed access wastes the hash overhead.
ToList() allocates a List<T> plus an internal T[] and copies each element. For a 1000-item source you've added one allocation, 1000 copies, and roughly 8 KB plus the array buffer. Cheap once, expensive in a hot loop.
Terminal operators that return a single value, not a collection. These run the chain but don't keep a snapshot around. Re-calling them re-runs the chain.
Each call triggered the chain. The short-circuit behavior of Any and FirstOrDefault: both stop iterating as soon as they have an answer. Count, Sum, and the other aggregates walk the full sequence.
If you call several aggregates on the same query, each one re-runs it. For a single source iteration, write your own reducer or use Aggregate to do all the work in one pass.
For a few aggregates on a small list, the convenience of Count + Sum + Max wins. For a huge sequence or an expensive source, one pass with Aggregate fits.
A short decision guide:
foreach the chain directly..ToList() once, iterate the list.IEnumerable<T> has no indexer.list.Count is O(1). Enumerable.Count() walks the chain.InvalidOperationException.IReadOnlyList<T> or IReadOnlyCollection<T> instead, so the type tells callers what they're getting.The signature of a method is a contract with the caller. IEnumerable<T> says "this is a recipe, run it when you're ready." IReadOnlyList<T> says "this is a finished list, you can index it and count it cheaply." Lining the type up with the behavior is the smaller half of getting this right; the bigger half is being honest about which one you intend.
What's wrong with this code?
A reader expecting "lengths of Headphones and Mouse" sees an extra value. The output is 10, 5, 8. The lengths query holds a reference to cart, and the mutation in between added a third item that the deferred Select picked up when string.Join iterated it.
Fix: snapshot when you need a stable view.
What's wrong with this code?
The predicate runs three times: once for Any, once for First (which short-circuits early), and once for Count (which walks the whole list). With a real source, that's three database round trips or three file reads.
Fix: materialize once.
What's wrong with this code?
The lambda captures i, not a snapshot of its value. By the time the queries run, i is 3, which throws an IndexOutOfRangeException when prices[i] is evaluated.
Fix: copy the loop variable into a local before capturing.
Or compute the threshold outside the lambda:
10 quizzes