Welcome back. In the previous lesson, delegates, lambdas, and events showed how C# can pass behavior around without tightly coupling classes. LINQ uses that same idea constantly: Where, Select, and similar operators accept lambda expressions, but—crucially—they often do not run those lambdas when the query variable is declared.
Priority: Must know. In about 40 minutes, you will practice predicting when a LINQ query runs, distinguish filtering from projection, explain what materialization changes, and identify the performance and correctness risks of multiple enumeration. These are common interview follow-ups, especially when a LINQ query represents database work, file processing, or an expensive transformation.
A LINQ query is usually a recipe, not a result
Consider a claim-processing example:
var approvedClaimIds = claims
.Where(claim => claim.Status == ClaimStatus.Approved)
.Select(claim => claim.ClaimId);
At this point, approvedClaimIds normally does not contain a ready-made collection of IDs. It represents instructions:
- Read claims from
claims. - Keep only approved claims.
- Return each surviving claim’s ID.
With ordinary LINQ to Objects, Where and Select return an IEnumerable<T> that evaluates later, when a consumer asks for items. This behavior is deferred execution.

The foreach in the diagram is one common execution trigger. Other triggers include a terminal operator such as Count(), First(), or ToList().
A useful interview formulation is:
Query declaration describes the work. Enumeration performs the work. For deferred LINQ queries, the predicate and selector run when a consumer requests results, not when the query variable is assigned.
This distinction matters because a deferred query sees the source at execution time, not necessarily as it looked when the query was written.
Part 14 LINQ query deferred execution
Watch “Part 14 LINQ query deferred execution” by kudvenkat for a compact visual demonstration of query definition, deferred execution, and ToList() forcing execution.
Watch the overview to establish the deferred versus immediate distinction. Then watch the deferred example, focusing on why adding an item after defining the query changes the eventual result. Finish with the ToList example, which contrasts a materialized result with a deferred query.
Interview pause: predict the output
Answer aloud before reading the model answer. Be precise about which statements run when.
var claims = new List<Claim>
{
new("C1", false),
new("C2", true)
};
var approvedIds = claims
.Where(claim =>
{
Console.WriteLine($"Filtering {claim.Id}");
return claim.IsApproved;
})
.Select(claim =>
{
Console.WriteLine($"Projecting {claim.Id}");
return claim.Id;
});
Console.WriteLine("Query defined");
claims.Add(new Claim("C3", true));
foreach (var id in approvedIds)
{
Console.WriteLine($"Result {id}");
}
Model answer
The first output is:
Query defined
No Filtering or Projecting message appears while the query is assigned. Where and Select create a deferred pipeline.
When the foreach begins, the source list is enumerated in its current state, including C3. The remaining output is:
Filtering C1
Filtering C2
Projecting C2
Result C2
Filtering C3
Projecting C3
Result C3
C1 reaches the filter but does not reach the projection because it is not approved. C2 and C3 pass the filter, so each is projected to its ID and yielded to the loop.
There are two senior-level observations here:
- Deferred execution makes processing pull-based. The consumer requests the next result, and the pipeline does only the work needed to produce it.
- Lambdas with side effects, such as logging, mutation, or network calls, are dangerous inside a query. Re-enumeration repeats those side effects. LINQ predicates and selectors should normally be pure transformations.
Filtering and projection are different operations
Interviewers often ask what this code does:
var claimSummaries = claims
.Where(claim => claim.Amount > 1_000m)
.Select(claim => new ClaimSummary(
claim.ClaimId,
claim.CustomerName,
claim.Amount));
A clear answer separates the two operations.
| Operation | Typical LINQ operator | Purpose | Example result |
|---|---|---|---|
| Filtering | Where | Decides whether an input item remains in the sequence | Removes claims at or below the threshold |
| Projection | Select | Transforms each input item into a new shape or value | Converts a Claim into a ClaimSummary |
| Materialization | ToList, ToArray, ToDictionary | Executes the sequence and stores results in a concrete collection | Creates an in-memory List<ClaimSummary> |
Filtering usually preserves the element type:
IEnumerable<Claim> highValueClaims = claims
.Where(claim => claim.Amount > 1_000m);
Projection often changes it:
IEnumerable<string> claimIds = claims
.Select(claim => claim.ClaimId);
Neither Where nor Select inherently materializes the results. In a normal LINQ to Objects pipeline, both are deferred and typically streaming: an item can be returned as soon as the pipeline has enough information to produce it.
For example:
var firstTwoApprovedIds = claims
.Where(claim => claim.Status == ClaimStatus.Approved)
.Select(claim => claim.ClaimId)
.Take(2);
If a caller iterates only those two IDs, the pipeline need not process the rest of the source after it has found two approved claims.
There is an important exception to the “one item at a time” intuition. Some operators are deferred but must buffer the source before yielding a result:
var newestFirst = claims
.OrderByDescending(claim => claim.CreatedAt);
OrderByDescending is still deferred: it waits until enumeration starts. But when it starts, it generally needs to inspect and sort all relevant input before it can safely produce the first item. GroupBy has similar buffering behavior.
Introduction to LINQ Queries - C#
Read Microsoft Learn’s “Introduction to LINQ Queries in C#” to reinforce the separation between creating a query and executing it, then connect that distinction to immediate execution and buffering.
Start in the “Three Parts of a Query Operation” section. Read the query lifecycle, noting the explicit difference between creating a query and executing it. Then, in “Classification of standard query operators by manner of execution,” read immediate execution and deferred execution. In the following “Streaming” and “Nonstreaming” subsections, focus on why sorting and grouping may need all source data before returning their first result. Finally, in “LINQ to objects,” read the materialization guidance. Pay attention to the difference between retaining a query recipe and storing its evaluated results.
Immediate execution and materialization
A query executes immediately when an operation requires an answer now.
Scalar terminal operations
These return one value, not an IEnumerable<T>:
var approvedCount = claims.Count(claim =>
claim.Status == ClaimStatus.Approved);
var hasRejectedClaim = claims.Any(claim =>
claim.Status == ClaimStatus.Rejected);
var largestAmount = claims.Max(claim => claim.Amount);
Count, Any, First, Single, Max, Min, Sum, and Average are typical immediate operators.
However, do not say that every immediate operation always reads the whole source. Explain the required work:
Any(predicate)can stop after the first matching item.First(predicate)can stop after the first matching item.Count(predicate)usually needs to inspect every item.Single(predicate)must continue far enough to prove whether a second match exists.MaxandAverageneed all relevant values.
That distinction demonstrates stronger reasoning than simply memorizing a list of “immediate” methods.
Materialization operators
Materialization means executing a sequence and storing its results in a concrete in-memory data structure:
var approvedClaimIds = claims
.Where(claim => claim.Status == ClaimStatus.Approved)
.Select(claim => claim.ClaimId)
.ToList();
At ToList(), the source is enumerated and the matching IDs are copied into a List<string>. The same principle applies to:
var idArray = query.ToArray();
var claimsById = query.ToDictionary(claim => claim.ClaimId);
var claimsByStatus = query.ToLookup(claim => claim.Status);
After materialization, enumerating the resulting List<T> does not rerun the original query. Its membership and ordering are a snapshot from the time of materialization.
Be exact about “snapshot”: ToList() copies the sequence of references or values; it does not deep-copy referenced objects. If a Claim object inside the list is later mutated, the list still points to that same object.
When should you materialize?
Materialize when you need one or more of these guarantees:
- You will deliberately enumerate the result more than once.
- You need stable membership while the original source may change.
- You need collection capabilities such as indexed access or the
List<T>.Countproperty. - You want to perform expensive or remote work once and process its result locally.
Do not add ToList() automatically to every LINQ query. It allocates memory and forces all results to be fetched. For a large or streaming source, that may harm latency and memory usage.
A concise interview answer is:
I materialize at a deliberate boundary: when I need a reusable in-memory snapshot or multiple passes over an expensive sequence. Otherwise, I preserve deferred execution so the caller can compose the query and consume only what it needs.
A brief provider caveat
With EF Core, a query often begins as IQueryable<T>, not ordinary in-memory IEnumerable<T>. The provider can translate the composed query into SQL, and a terminal operation such as ToListAsync(), FirstOrDefaultAsync(), or CountAsync() causes database execution.
The principle is the same: building the query is distinct from executing it. But the consequences are greater: repeated enumeration or repeated terminal calls can mean repeated database commands. You will examine EF Core query shaping and N+1 issues later in the course.
Multiple enumeration: a hidden second execution
Consider this service method:
public static void ProcessApprovedClaims(
IEnumerable<Claim> approvedClaims)
{
var count = approvedClaims.Count();
logger.LogInformation(
"Processing {Count} approved claims", count);
foreach (var claim in approvedClaims)
{
Process(claim);
}
}
The method looks harmless, but it may enumerate approvedClaims twice:
Count()walks the sequence to calculate the count.- The
foreachwalks it again to process each claim.
If approvedClaims is already a List<Claim>, that may merely be two inexpensive passes through memory. But its static type, IEnumerable<Claim>, does not guarantee that.
It might instead be:
- an iterator created with
yield return; - a deferred LINQ pipeline performing costly calculations;
- a sequence reading a file;
- a sequence that calls an external service;
- a query backed by a database provider;
- a sequence whose results change between passes.
In those cases, the second enumeration can create a performance problem, duplicate side effects, or produce inconsistent results.
How IEnumerable can kill your performance in C#
Watch “How IEnumerable can kill your performance in C#” by Nick Chapsas for a practical demonstration of why calling Count() and then iterating a deferred sequence can repeat file or other I/O work.
Watch the double processing example. Focus on the debugger walkthrough: the iterator does no work when assigned, runs for Count(), then runs again for foreach. Then watch the tradeoff and fix, noting both the value of deferred composition and the memory cost of materializing with ToList() or ToArray().
A safe improvement, if this method genuinely needs the count and then a full pass, is to materialize once at the boundary:
public static void ProcessApprovedClaims(
IEnumerable<Claim> approvedClaims)
{
var materializedClaims = approvedClaims.ToList();
logger.LogInformation(
"Processing {Count} approved claims",
materializedClaims.Count);
foreach (var claim in materializedClaims)
{
Process(claim);
}
}
Now the expensive sequence is consumed once. The method pays memory proportional to the result size, but it gains predictable, repeatable processing.
Microsoft’s CA1851 analyzer rule flags patterns that may enumerate an IEnumerable more than once. Treat it as a prompt to investigate, not as an automatic instruction to add ToList():
- If the underlying value is known to be a collection, multiple passes may be acceptable.
- If the source is unknown, expensive, side-effecting, or remote, materialization may be justified.
- If the sequence can be huge, redesigning the method to make one pass may be better than loading everything into memory.
For example, if only a count is needed for logging, perhaps logging after processing—or maintaining a count during the single loop—is a better design:
var processedCount = 0;
foreach (var claim in approvedClaims)
{
Process(claim);
processedCount++;
}
logger.LogInformation(
"Processed {Count} approved claims",
processedCount);
Senior interview drill: diagnose and improve the code
Answer aloud for two to three minutes before reading the model answer.
A method receives
IEnumerable<Claim>. It callsAny()to return early if there are no claims, callsCount()for audit logging, and then iterates the sequence to save each claim. A production incident shows that the input is sometimes generated from a file and sometimes comes from a database query. How would you assess the problem and choose a fix?
Model answer
I would first identify that the current method can enumerate the input up to three times: once for Any(), once for Count(), and once in the processing loop. Whether that is harmful depends on the actual input implementation, not just its IEnumerable<Claim> type.
For a list, the repeated passes may be cheap, although Count() should ideally use a collection count when one is known. For a file-backed iterator or deferred database query, each pass can repeat I/O or database work. It may also see different data each time, which can make the audit count disagree with the number actually saved.
If the business operation requires a stable, bounded set of claims, I would materialize once at an appropriate boundary, then use the resulting list for the emptiness check, count, audit record, and processing. That creates a memory trade-off, so I would validate expected result size.
If the input can be very large, I would prefer a one-pass design: process records incrementally while tracking the count, and write final audit information after processing. For a database-backed workflow, I would also clarify transaction boundaries and whether “the count” must represent an exact snapshot of records successfully processed.
The key decision is not “always use ToList().” It is choosing between streaming and a materialized snapshot based on cost, consistency requirements, and expected volume.
A practical interview checklist
When shown LINQ in an interview, reason through these questions in order:
-
What is the source?
An in-memory list, an iterator, a file, a database query, or an external data source each has different cost and consistency behavior. -
Which operators compose the pipeline?
Wherefilters;Selectprojects. Most sequence-returning operators are deferred. -
What triggers execution?
Look forforeach,ToList,ToArray,Count,Any,First,Single, or provider-specific async terminal methods. -
Will execution stream or buffer?
WhereandSelectcommonly stream. Sorting and grouping often buffer before producing results. -
How many times is the sequence consumed?
Count terminal operations and loops. Repeated consumption may mean repeated expensive work. -
What result semantics are required?
Choose deferred streaming for composability and low memory use; choose materialization for reuse, a stable membership snapshot, or multiple passes.
Key takeaways
- A LINQ query variable usually stores a recipe, not computed results.
WhereandSelectcommonly use deferred execution. - Filtering with
Wheredecides which items remain. Projection withSelecttransforms surviving items into a different value or shape. foreachand scalar operations such asCount()orFirst()trigger execution. Immediate execution does not always mean reading every item;Any()andFirst()may short-circuit.ToList(),ToArray(),ToDictionary(), andToLookup()materialize results: they execute now and create an in-memory collection.- Deferred execution is useful, but multiple enumeration can repeat expensive work, repeat side effects, or yield inconsistent results.
- Materialize intentionally when repeated passes or a stable snapshot are required; otherwise, prefer a one-pass design or retain deferred composition.
Next, you will trace how Task, async, and await affect control flow, exceptions, and concurrency in a short C# operation—another area where “when does this actually run?” is central to a strong interview answer.
Can't find a good explanation? Sign up and we'll make it for you
Sign up