How it works
The explanation, with examples you can run.
Deferred execution
var numbers = new List<int> { 1, 2, 3 };int calls = 0;var evens = numbers.Where(n => { calls++; return n % 2 == 0; });Console.WriteLine($"after Where: {calls} calls");numbers.Add(4);Console.WriteLine($"count: {evens.Count()}, calls: {calls}");Console.WriteLine($"count: {evens.Count()}, calls: {calls}");var snapshot = evens.ToList();numbers.Add(6);Console.WriteLine($"snapshot: {snapshot.Count}, live: {evens.Count()}");On .NET 10 this printed:
after Where: 0 callscount: 2, calls: 4count: 2, calls: 8snapshot: 2, live: 3Where ran the predicate zero times. Each Count() ran it over all four elements again. ToList() captured a snapshot that later changes don't affect. The operators also stream: Where and Select pass elements on one at a time, so First() stops at the first match. Some operators, such as OrderBy, GroupBy and Reverse, must read the whole source before producing the first result.
The rule of thumb: if you'll enumerate a query more than once, or the source may change, call ToList() or ToArray() once and reuse the result. Return IReadOnlyList<T> from methods that have already done the work, so callers don't need to wonder.
Two kinds of lambdas
There are two sets of LINQ methods with the same names:
Enumerable.Where(this IEnumerable<T>, Func<T, bool>): the lambda compiles to a delegate, and LINQ to Objects calls it for each element in memory.Queryable.Where(this IQueryable<T>, Expression<Func<T, bool>>): the compiler builds an expression tree, a data structure describing the lambda. The query provider (EF Core, for example) reads the tree and translates it, into SQL in EF Core's case.
The compiler picks one by the static type of the source. A DbSet<Order> is an IQueryable<Order>, so db.Orders.Where(o => o.Total > 100) becomes WHERE "Total" > 100. Pass the same DbSet to a method whose parameter is IEnumerable<Order> and the same-looking Where becomes Enumerable.Where: EF Core loads every row and the filter runs in C#.
Since EF Core 3.0, a query whose filter can't be translated (because it calls one of your own C# methods, for instance) throws rather than silently evaluating on the client, except in the final Select, where untranslatable parts are run in memory on the results. If you really want to continue in memory, say so with AsEnumerable() after the parts the database should do.
Performance notes
LINQ to Objects allocates enumerators and delegates, and lambdas that capture variables allocate a closure. In hot loops that matters; in most code it doesn't. Each recent release has made common operators faster, and the runtime team publishes the numbers every year in its performance blog posts.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.