Deferred Execution
A LINQ query is a recipe, not a result: it runs when you enumerate it, and it runs again every time you enumerate it.
IEnumerable<T> query = source.Where(...); List<T> results = query.ToList();Calling Where does not filter anything. It returns an object that remembers the source and the test, and does nothing else. The filtering happens later, when something walks that object: a foreach, a string.Join, a call to Count(). This is called deferred execution, or lazy evaluation. Almost every operator that returns a sequence behaves this way.
What follows from it
First, the query reads the source as it is at enumeration time, not as it was when you wrote the query. Add an item to the list afterwards and the query will see it. Second, a variable the lambda uses is also read at enumeration time, so changing that variable changes the query's meaning. Third, enumerating twice does the work twice. A query is not a cache.
The fix
ToList() and ToArray() run the query once and copy the results into a real collection. After that the data is yours: later changes to the source cannot reach it, and reading it again costs nothing. ToDictionary() and ToHashSet() do the same into other shapes. new List<int>(query) is the longhand for query.ToList() and does the same work.
Which operators run immediately
Anything that returns a single value has to: Count, Sum, Any, First, Single, Max. Anything that returns a sequence is deferred, including OrderBy and GroupBy — though those two must read the whole source before yielding their first element, so they cannot stream.
Example
List<int> numbers = [1, 2, 3];
// Where filters nothing yet. It returns an object holding the source and
// the test. No element has been looked at.
IEnumerable<int> evens = numbers.Where(n => n % 2 == 0);
// Change the source after writing the query.
numbers.Add(4);
// Enumerating now runs the test over the list as it is today. The 4 is in.
Console.WriteLine(string.Join(", ", evens));
// A variable the lambda captured is read at enumeration time too, so this
// query means "less than 10", not "less than 2".
int limit = 2;
IEnumerable<int> small = numbers.Where(n => n < limit);
limit = 10;
Console.WriteLine(string.Join(", ", small));
// ToList runs the query once and copies the results out. The snapshot is
// finished data; the query variable is still a live recipe.
List<int> snapshot = numbers.Where(n => n % 2 == 0).ToList();
numbers.Add(6);
Console.WriteLine(string.Join(", ", snapshot));
Console.WriteLine(string.Join(", ", evens));
// Output:
// 2, 4
// 1, 2, 3, 4
// 2, 4
// 2, 4, 6When to use it
- Return a List<T> from a repository method instead of IEnumerable<T>, so callers cannot re-run the database query by accident.
- Snapshot a filtered collection before mutating the underlying list, so the snapshot does not change underneath you.
- Diagnose a method that looks slow because a caller iterates the IEnumerable<T> it returns three times.
More examples
Counting how many times the work really happens
This is deferred execution's real cost. Over an in-memory array nine calls cost nothing, but swap the array for a database query and those three lines are three round trips. The habit that avoids it: the moment you need a query's results more than once, assign query.ToList() to a variable and work from that. It also shows First short-circuiting, which is why Any beats Count() > 0.
string[] source = ["alpha", "beta", "gamma", "delta"];
int lambdaCalls = 0;
// A lambda body can be a block instead of a single expression, which lets
// it count its own calls. lambdaCalls is an ordinary local that the lambda
// shares with the surrounding code.
IEnumerable<string> longOnes = source.Where(s =>
{
lambdaCalls++;
return s.Length > 4;
});
// Each of these three lines walks the query from the start again. Count
// tests all four elements, First stops at the first match, ToList tests
// all four: 4 + 1 + 4 = 9 calls for what looks like one query.
int count = longOnes.Count();
string first = longOnes.First();
List<string> all = longOnes.ToList();
Console.WriteLine($"{count} {first} {all.Count} calls={lambdaCalls}");
// Output:
// 3 alpha 3 calls=9The operators that materialise a query
Use ToList when you will read the results several times or hand them to code that might read them several times. Use ToArray when the size is fixed and nothing will be added. Use ToDictionary to build a lookup you will hit by key, remembering that it throws ArgumentException if two elements produce the same key. The example also makes the counting point from the previous one: four To-calls walk the same query four times.
Product[] catalogue =
[
new Product("pen", 2),
new Product("mug", 9),
new Product("pad", 4)
];
IEnumerable<Product> cheap = catalogue.Where(p => p.Price < 5);
// Each of these runs the query right now and returns a new collection that
// holds the results. The query is walked once per call, so four calls here
// means four walks.
List<Product> asList = cheap.ToList();
Product[] asArray = cheap.ToArray();
Dictionary<string, int> byName = cheap.ToDictionary(p => p.Name, p => p.Price);
HashSet<string> names = cheap.Select(p => p.Name).ToHashSet();
// Aggregates and element operators are immediate as well. They have to be:
// they return a value, not a sequence, so there is nothing to defer.
int total = cheap.Sum(p => p.Price);
Console.WriteLine($"{asList.Count} {asArray.Length} {byName["pad"]} {names.Count} {total}");
// Output:
// 2 2 4 2 6
record Product(string Name, int Price);
Discussion