A C# LINQ Where call can look convincing in a code review while contributing nothing to the result that a caller receives. The predicate may be correct, compilation may succeed, and the application may display a plausible list. The missing connection is often simpler than a complicated query bug: the code discarded the sequence returned by Where and continued using the original collection.
The central rule is that Enumerable.Where returns a filtered sequence; it does not remove elements from its source list. Retaining that return value is the first requirement. Choosing when to enumerate it, and whether to materialize its results, is the next. This guide separates those decisions with a small C# console project using synthetic data only.
A filter only helps if its result reaches the consumer
Keep the sequence returned by Where and pass that sequence to the consumer. Calling the method does not replace the original variable or remove source elements. If the final operation still reads the original list, a correctly written predicate can have no effect on the delivered result.
The source note behind this article identifies discarded LINQ return values as a review hazard. Its useful contribution is the failure pattern, not the surrounding application history. This guide does not reproduce project identifiers, historical row counts, or outcomes from that environment. Instead, the test uses four integers whose expected membership is immediately visible.
A useful mental model is a three-stage path: source, query composition, and consumption. The source owns the starting data. Query composition describes the selection. Consumption enumerates the composed query and uses the selected elements. A broken link at the last stage is enough to undo otherwise correct filtering. Review the final return statement or rendering input, not just the line containing the predicate.
Types make this distinction concrete. A variable inferred as List cannot automatically receive every sequence returned by LINQ. Use an IEnumerable variable when progressively composing an in-memory query. Assign each additional Where result back to that query variable, or express the operations as a clear chain. Materialize into a list when the consumer actually requires that representation or when a deliberate timing boundary is needed.
Adding ToList after every operator is not a general repair. It can make some assignments compile, but it can also introduce unnecessary intermediate collections while leaving the wrong final variable in place. First establish which result must reach the caller. Only then decide whether the caller needs a sequence to enumerate or a concrete collection captured at a particular moment.
Deferred execution is not a snapshot of query creation
A deferred query normally evaluates its predicate when the sequence is enumerated, not when the query variable is assigned. Changes to the source or captured variables before enumeration can therefore change the result. If a consumer requires fixed membership, choose an explicit materialization point rather than assuming query creation captured the data.
The Microsoft documentation for Enumerable.Where describes deferred execution: the returned object stores the information needed to perform filtering, and enumeration performs that work. In the first test, the discarded query contains a counter in its predicate. The counter remains zero because that query is never enumerated. The counter is an observation device for this test, not a recommendation to put business side effects inside filtering predicates.
Captured variables deserve particular attention. A predicate such as value > threshold refers to the captured variable. Creating the query and then assigning a different value to threshold can change the subsequent selection. The code did not capture an immutable historical value simply because the lambda was written earlier. If the intended contract requires a stable threshold, make that ownership and lifetime explicit before the query is consumed.
The test changes the threshold before enumerating, materializes the result, and then adds a new source element. Re-enumerating the deferred query sees the new element. Reading the materialized integer list does not. Both outcomes are correct for their respective contracts. A live view and a fixed result are different products, and the calling code should not have to guess which one it received.
Repeated enumeration is another consequence. A consumer might count a sequence and then enumerate it again to display the elements. That can perform predicate work more than once. With pure predicates and unchanged input, it may merely repeat work. With changing input or external state, it may produce a different result. This guide does not claim that deferred sequences provide thread safety or a consistent view across concurrent changes.
ToList fixes membership, not every object ownership problem
ToList immediately enumerates a sequence and creates a new list, which captures that enumeration's membership. It does not deep-copy reference-type elements. A second container can still hold the same mutable objects, so isolating element state requires an explicit projection or another clearly defined copying strategy.
For the integer example, materialization gives a straightforward boundary. Adding an integer to the source afterward does not append it to the new list. That observation is about collection membership. It does not imply that all values reachable through a materialized result become immutable, detached, or independent from their original objects.
The third test introduces a deliberately small Entry type with an integer identifier and a writable string property. Calling ToList produces a different list object, which is checked with ReferenceEquals. The first element in each list is nevertheless the same Entry object. Assigning a new label through the source changes the label observed through the shallow copy as well.
The alternative in the test uses Select to construct a new Entry for each result, copying the identifier and label. Subsequent assignment to the original object's label does not change that projected object's label. This is sufficient for the example's two-property contract. It is not a universal deep-copy implementation. A model containing mutable nested collections would require another explicit ownership decision for those collections.
This distinction is included to prevent an overstatement about ToList, not to replace a broader guide to shared cache object isolation. The main problem here remains preserving a filter result and controlling its enumeration boundary. Copying the container and copying element state are separate decisions that happen to meet at the same API call in many applications.
Verification: three checks in a pinned C# console project
Create an isolated local project with the three files below, select the pinned SDK, and run the Release configuration. The checks cover discarded results, deferred enumeration, and shallow element sharing. Each contract is enforced by an explicit assertion that throws on failure, rather than relying on output that merely looks plausible.
Prerequisites are .NET SDK 10.0.400, a writable test directory, and the normal toolchain facilities needed for the initial restore. The target framework is net10.0. There are no third-party package references. This SDK is the reproducibility baseline for the example, not a claim that every application should upgrade to it or that it will always be the newest release.
- In a new empty directory, save the following files as
global.json,LinqLab.csproj, andProgram.cs. Do not overwrite an existing application. Keep this experiment separate from unrelated project files and settings. - From that directory, run
dotnet --versionand require10.0.400. Then rundotnet run --project LinqLab.csproj --configuration Release. If the SDK is unavailable, satisfy the prerequisite first; a toolchain setup failure is not evidence that a LINQ assertion failed. - Require
CHECK1,CHECK2,CHECK3, andALL PASS, together with an exit code of zero. An exception, a missing marker, or a build failure means the run did not pass. Do not substitute an earlier run's output for the current result.
global.json:
{"sdk":{"version":"10.0.400","rollForward":"disable"}}
LinqLab.csproj:
Exe
net10.0
enable
enable
Program.cs:
using System.Runtime.InteropServices;
static void Check(bool condition, string name)
{
if (!condition) throw new InvalidOperationException(name);
}
var source = new List { 1, 2, 3, 4 };
var predicateCalls = 0;
source.Where(value => { predicateCalls++; return value % 2 == 0; });
Check(predicateCalls == 0, "discarded query executed unexpectedly");
Check(source.SequenceEqual(new[] { 1, 2, 3, 4 }), "source changed");
IEnumerable filtered = source;
filtered = filtered.Where(value => value % 2 == 0);
var firstResult = filtered.ToArray();
Check(firstResult.SequenceEqual(new[] { 2, 4 }), "filter result mismatch");
Check(source.Count == 4, "filter mutated source");
Console.WriteLine("CHECK1 PASS discardedCalls=0 sourceCount=4 filtered=2,4");
var threshold = 2;
var deferred = source.Where(value => value > threshold);
threshold = 3;
Check(deferred.SequenceEqual(new[] { 4 }), "captured threshold mismatch");
var snapshot = deferred.ToList();
source.Add(5);
Check(deferred.SequenceEqual(new[] { 4, 5 }), "deferred result mismatch");
Check(snapshot.SequenceEqual(new[] { 4 }), "materialized result changed");
Console.WriteLine("CHECK2 PASS deferred=4,5 snapshot=4");
var items = new List { new() { Id = 1, Label = "before" } };
var shallow = items.ToList();
Check(!ReferenceEquals(items, shallow), "list was not copied");
Check(ReferenceEquals(items[0], shallow[0]), "element reference changed");
var projected = items.Select(item => new Entry { Id = item.Id, Label = item.Label }).ToList();
items[0].Label = "after";
Check(shallow[0].Label == "after", "shallow reference mismatch");
Check(projected[0].Label == "before", "projection isolation mismatch");
var empty = source.Where(value => value > 100).ToArray();
Check(empty.Length == 0, "empty selection mismatch");
Console.WriteLine("CHECK3 PASS shallow=after projected=before emptyCount=0");
Console.WriteLine($"ALL PASS runtime={RuntimeInformation.FrameworkDescription}");
sealed class Entry
{
public int Id { get; init; }
public string Label { get; set; } = "";
}
Complete commands, executed from the directory containing those files:
dotnet --version
dotnet run --project LinqLab.csproj --configuration Release
Executed in the scheduler environment. The selected SDK was 10.0.400; the application reported runtime .NET 10.0.11; the process exited with code zero. The following is the actual output. Its small counts describe the synthetic test fixture, not a benchmark, a database measurement, or a personal achievement.
CHECK1 PASS discardedCalls=0 sourceCount=4 filtered=2,4
CHECK2 PASS deferred=4,5 snapshot=4
CHECK3 PASS shallow=after projected=before emptyCount=0
ALL PASS runtime=.NET 10.0.11
The first check establishes that the discarded predicate has not run, the source still contains four integers, and consuming the retained filter yields two and four. The second checks the captured threshold, the changed source, and the already materialized result separately. The third checks distinct containers, shared element references, projection isolation for this model, and an empty-result case. The assertions make the expected outcomes part of the executable artifact.
Review the output contract, not only the predicate syntax
Trace the composed query all the way to its consumer and define when enumeration is allowed. Test identifiable members rather than relying only on counts. A small fixture with matching and nonmatching elements can expose a discarded filter more reliably than a large example in which every element happens to qualify.
For a practical review, start with three input categories: partial matches, no matches, and all matches. The all-match case alone is weak because returning the unchanged source may appear correct. An empty-result case checks that the consumer respects an empty selection rather than silently substituting the source. Partial matches show whether the actual identities delivered to the caller satisfy the intended condition.
Counts are useful but insufficient. Two different selections can contain the same number of elements. Compare identifiers or complete values when the contract allows it. If ordering matters, specify and test ordering as a separate requirement. Do not infer an application's sorting contract simply because a tiny source fixture happened to arrive in a convenient order during one run.
Query ownership also matters. Identify which layer composes conditions, which layer is permitted to enumerate, and which layer decides to materialize. A vague variable name such as data can hide the difference between a deferred view and a captured list. Clear naming is not a substitute for tests, but it makes the contract easier to follow during maintenance.
Use this compact review checklist:
- Return value: Does each filtering operation contribute its returned sequence to the next stage?
- Consumer: Does the final output use the composed query rather than the original source?
- Timing: Can captured values or source contents change before enumeration?
- Ownership: Does a new list still share mutable elements with the source?
- Failure: Can the tests distinguish an empty selection, incorrect members, and incomplete execution?
Avoid treating materialization as a universal performance improvement. Its costs depend on result size, enumeration frequency, and the consumer's needs. This article measures no throughput or memory advantage. Its goal is a correct, observable contract before any separate optimization exercise begins.
Scope, evidence, and the limits of these results
These tests establish Enumerable behavior over in-memory collections under a specified toolchain. They do not establish a database provider's translation rules or execution plan. The source note supplies the problem pattern, official documentation supplies API contracts, and the local execution supplies evidence for the concrete assertions shown here.
An IQueryable pipeline can represent operations as expression trees for a provider to interpret. Translation support, data access timing, and ordering behavior then require their own verification. No database was connected for this article. No SQL generation behavior or query speed improvement is claimed. That separates this search intent from EF Core query performance and nullable-expression translation topics already covered elsewhere on the site.
The wording about nonmutation also needs a boundary. Where itself does not remove source elements as part of filtering. That is narrower than claiming that all code inside a LINQ pipeline is free of side effects. A developer can write a predicate that changes external state, and that choice needs separate analysis. The counter in the first test makes deferred execution visible; it should not be mistaken for a design pattern for application actions.
Official references:
- Enumerable.Where: returned sequences and deferred execution
- Introduction to LINQ queries: creation, execution, and immediate materialization
- Enumerable.ToList: constructing the result list
Next step: run the three local checks, then trace one real Where call from its returned value to the consuming statement in your test project. Replace “the condition is present” with the stronger requirement “the selected members were verified.” That is the difference between plausible query code and a tested result contract.