C# / Intermediate: Composition and Asynchronous Work / Lesson 8
LINQ and Deferred Enumeration
Predict when queries execute, what they observe, and which materialization or ordering guarantees callers actually receive.
On this page
Lesson goals
- Separate query construction from enumeration.
- Choose materialization and ordering deliberately.
- Distinguish IEnumerable execution from provider-translated IQueryable.
A query declaration may not execute the work
LINQ provides composable operations over data sources. Many sequence-returning operators, such as Where and Select on IEnumerable<T>, use deferred execution: creating the query does not immediately traverse the source. Enumeration performs the work and can observe source state at that later time. Some operators buffer internally, while terminal operations can execute immediately.
Do not infer timing from fluent syntax alone. A chain can describe work without doing it, and exceptions from a predicate can occur during enumeration rather than construction. Keep the source alive for the query’s actual lifetime and avoid returning a deferred query over a resource already disposed by its creator.
Observe the source at enumeration time
The first program creates an odd-number query, modifies the list, and only then enumerates through string.Join. The added five is included because enumeration happens after the addition. The example intentionally mutates before enumeration starts; it does not claim that structural mutation during an active list enumeration is safe.
This behavior can be useful for a live view but surprising for an API expected to return a captured result. Document the contract explicitly. A variable named results does not imply a snapshot, and a query assigned to var does not change the execution semantics. Test the timing that callers rely on.
using System;
using System.Collections.Generic;
using System.Linq;
var source = new List<int> { 1, 2, 3 };
var odd = source.Where(value => value % 2 != 0);
source.Add(5);
Console.WriteLine(string.Join(",", odd));
1,3,5
Where builds a recipe; string.Join enumerates it after five is inserted.
Materialize when the lifetime requires it
ToArray and ToList enumerate the source and store element values immediately. The second example captures the query before modifying the source. The array remains one and three, while re-enumerating the original query sees five. This creates separate outer storage, not a recursive copy of mutable reference elements.
Materialization consumes time and memory and can turn an infinite or huge sequence into a resource problem. Bound untrusted input and choose the required result size. For a source shared with writers, materialization itself needs a synchronization or immutable-source argument; calling ToList does not magically make an unsafe enumeration safe.
using System;
using System.Collections.Generic;
using System.Linq;
var source = new List<int> { 1, 2, 3 };
var query = source.Where(value => value % 2 != 0);
int[] captured = query.ToArray();
source.Add(5);
Console.WriteLine(string.Join(",", captured));
Console.WriteLine(string.Join(",", query));
1,3
1,3,5
Materialization occurs before the addition, while the second enumeration occurs after it.
Specify ordering and avoid repeated work
Where preserves the enumeration order of its source; OrderBy establishes a key order under its comparer. A source with no promised order does not acquire one merely by filtering. Choose an ordinal comparer for stable protocol strings and define tie behavior when externally visible ordering matters. Do not rely on incidental dictionary ordering.
Calling Count, Any, and then iterating an unknown query can repeat work or produce different observations. Use one pass or materialize where justified. Avoid side effects in predicates and selectors when they make repeated enumeration surprising. A readable loop can be better than a dense query whose cost and state changes are hidden.
Provider translation is a separate execution contract
IQueryable<T> can represent expression trees interpreted by a provider, while ordinary IEnumerable<T> operators execute .NET delegates during enumeration. Identical-looking query syntax can therefore have different supported operations, null behavior, performance, and execution location. This language course uses in-memory LINQ and makes no claim about a database provider’s generated query.
Keep provider-specific integration tests in the application that actually uses that provider. For in-memory code, test empty sources, repeated enumeration, a source changed before execution, and failing predicates. Name APIs to distinguish a deferred view from an eagerly captured collection when that difference matters to resource lifetimes.
Common errors and fixes
An API result changes after its source changes.
- Why it happens
- It returned a deferred view instead of a captured collection.
- How to fix it
- Materialize at the intended boundary or document live semantics.
- Prevent it next time
- Test mutation before enumeration.
A query fails after its creator returned.
- Why it happens
- The underlying resource was disposed before enumeration.
- How to fix it
- Enumerate within the owned lifetime or transfer ownership deliberately.
- Prevent it next time
- Review deferred execution across using scopes.
A query runs several expensive passes.
- Why it happens
- Terminal checks repeatedly enumerate an unknown source.
- How to fix it
- Use a single pass or a bounded materialization.
- Prevent it next time
- Inspect execution count as part of the contract.
Check your understanding
1. A method returns File.ReadLines(path).Where(...) from inside a scope owning a separate reader. What question must be answered before considering the result safe?
Show worked solution
Identify which object actually owns enumeration and when the file or reader is opened and disposed. A deferred query must not depend on a reader already disposed at return. Either enumerate while ownership is valid and return a bounded captured result, or expose a documented lazy API whose own iterator manages acquisition and cleanup. Do not assume a using around query construction covers later iteration.
2. Why does captured = query.ToArray not imply deep independence for a query returning mutable Customer objects?
Show worked solution
The array copies each Customer reference, so both sources can still reach the same objects. Replacing an array slot is independent, but mutating a Customer through that reference can be shared. Use immutable customer projections or an explicit copying policy if the result must be a stable snapshot of field values. State that policy in the API contract.
Key takeaways
- Query construction, enumeration, and materialization happen at distinct times.
- A materialized outer collection can still contain shared objects.
- Provider translation and in-memory delegates are separate contracts.