Source, query and consumption
In this lesson, LINQ-to-Objects means Enumerable operations over arrays, lists and other local sequences. Where selects elements through a predicate and Select transforms each element; neither operation implies changing the original collection.
Writing var q = readings.Where(r => r.Accepted) defines a query. Obtaining results requires consuming it, for example with foreach or ToList. If the local source is empty, a valid filter produces an empty sequence, which may or may not satisfy the business rule.
Start with the question: do I need all eligible records, a total or exactly one record? That decision precedes the choice of operator. A similar expression over IQueryable can be interpreted by a remote provider.
Do not transfer local execution assumptions to SQL, network costs or function translation without consulting that provider.
Reading time is part of the contract
A deferred query can observe changes made before enumeration. If a list contains 1 and 4, Where(x => x > 2) is defined and 6 is then added, later enumeration yields 4 and 6. This differs from changing a List while an enumerator is already active, which can invalidate it.
Captured parameters also need attention: a lambda using the local variable limit reads that variable when called. Changing limit between definition and consumption can change selection. To retain a threshold, store its value in a dedicated local that is not changed; this retains the parameter but does not freeze the source.
Document separately when criteria become fixed and when data is captured. A name such as acceptedQuery does not demonstrate that acceptance has already been evaluated.
Materialization retains results, with copying limits
ToList consumes a query and stores the resulting elements in a new list. Reading that list again avoids repeating the original query. Adding an element to the source list later does not add it to the result list, but both can contain references to the same objects.
If Reading is a mutable class, changing Amount on an object remains visible through any reference to that object. For a stable report, project the required fields into owned values before materialization: for example, a new record with a string ID and decimal Amount.
Do not put a shared mutable list inside that record and then assume a deep copy. Capture must also follow a policy preventing changes during collection; ToList supplies neither a transaction nor synchronization. Decide the volume limit too: retaining all results requires memory proportional to the retained set.
Cardinality should express the business rule
Single expresses a requirement for exactly one eligible element. Two elements with the same value are still two: Single over 7, 7 fails even though there is only one distinct value. This check is useful when any cardinality deviation should interrupt the operation.
If the consumer must distinguish missing data from conflict, do not indiscriminately map Single’s exception to missing. In a small stable batch, materializing matches and classifying Count as zero, one or several makes the three outcomes explicit.
FirstOrDefault over int can return 0 because the source is empty or because the first value is actually zero. When zero is valid, retain presence information separately. Choosing an API because it avoids an exception, without preserving these states, can turn a data error into silent acceptance.
Primary ordering and tie-breaking
For ascending Priority and ascending Age within each priority, use OrderBy(x => x.Priority).ThenBy(x => x.Age). A second OrderBy starts another primary ordering; it does not mean adding a tie-breaker to the first. For records A=(1,9), B=(2,1), C=(1,3), the required order is C,A,B, whereas giving Age precedence produces B,C,A.
Sort stability preserves relative order among elements with equal keys, but does not create unique identity. If the source can arrive in another order and the report requires reproducible results, add an appropriate tie-breaker, such as a unique ID.
For textual protocol IDs, specify the intended comparer, for example StringComparer.Ordinal. LINQ ordering produces an ordered sequence; it is not a call that rearranges the original List in place.
Diagnose the phase where failure occurs
In new[] { 2, 0 }.Select(x => 10 / x), creating the query does not perform the divisions. ToList consumes the projection, encounters zero and does not normally return a partial list. A try that ended after query definition does not cover the later failure.
Identify the source, selector and consumption point during diagnosis. This does not mean every error is deferred: invalid arguments can be rejected when an operator is called. Keep predicates and selectors free of external effects where possible.
Two enumerations can evaluate the query again; callback counts inferred from a terminal operator can also depend on optimizations. A report transformation should not charge a payment or update business state as a hidden Select effect. Where effects exist, make their execution and recovery explicit.
Guided reading: one capture for two consumers
The example receives a finite non-null local list with no null elements and unique non-null ASCII IDs and no changes during the call. Capture accepts a decimal threshold, filters Accepted and Amount, projects each match into a new ReportRow, orders by descending Amount and breaks ties by ordinal ID.
ToList completes capture before returning the result. For B/12/true, A/12/true and C/5/false, with threshold 10, the sequence is A/12, B/12. If the caller later changes A’s Amount in the source list, report values remain because the decimal was already copied.
The returned list remains mutable and must be shared by the two consumers without modification; the function does not enforce that discipline. Also consider the limits: duplicate IDs, a concurrent source or unbounded volume require additional contracts.
This lesson’s questions ask you to interpret decisions and snippets without executing code.
using System;
using System.Collections.Generic;
using System.Linq;
public sealed class Reading
{
public required string Id { get; init; }
public decimal Amount { get; set; }
public bool Accepted { get; set; }
}
public sealed record ReportRow(string Id, decimal Amount);
public static class Reporting
{
public static List<ReportRow> Capture(List<Reading> source, decimal minimum)
{
return source
.Where(r => r.Accepted && r.Amount >= minimum)
.Select(r => new ReportRow(r.Id, r.Amount))
.OrderByDescending(r => r.Amount)
.ThenBy(r => r.Id, StringComparer.Ordinal)
.ToList();
}
}With unique IDs and no changes during Capture, B/12/true, A/12/true and C/5/false, with threshold 10, produce A/12 and B/12. Reading the snippet is sufficient to answer.
Common pitfalls
Confusing queries with results; treating ToList as a deep copy; capturing a changing threshold; hiding duplicates; replacing ThenBy with OrderBy; handling exceptions only at definition.
Related topics: Collections, generics and equality · Objects, interfaces and records · Exceptions, resources and files
Make reading time, retained values and accepted cardinality explicit before choosing the LINQ chain.
Reference: Introduction to LINQ queries · C# 14 / .NET 10; no preview features