Lesson 1 of 4 | LINQ to Objects
Deferred Execution and Repeated Enumeration in LINQ to Objects
Learning outcome
By the end of this lesson, you should be able to explain three interview-critical ideas:
- A LINQ to Objects query is usually deferred until you enumerate it.
- Enumerating the same query more than once can repeat the source traversal, filters, projections, and side effects.
- This behavior is different from database LINQ providers, where an expression tree may be translated into SQL and executed by the database rather than by LINQ to Objects.
Intuition
Think of an IEnumerable<T> query as a recipe, not a prepared meal. Building the query does not do the work; enumerating it does.
That means this pattern is common:
- define query
- inspect it in a debugger or print it as a variable
- enumerate it once and see the work happen
- enumerate it again and see the work happen again
This surprises developers when the query includes:
- expensive calculations
- logging
- random number generation
- reading from a moving data source
- side effects in predicates or selectors
A query is only “cached” if you explicitly materialize it, for example with ToList() or ToArray().
Deep dive
In LINQ to Objects, operators like Where, Select, Skip, and Take typically create an iterator pipeline. The pipeline is lazy: it pulls elements from the source only as needed during enumeration.
The important consequence is that enumeration is repeatable work. If the source is an in-memory collection and you loop over the query twice, the query logic runs twice.
This is different from some database providers such as Entity Framework Core. There, the query is usually an expression tree first. The provider may translate it to SQL, send it to the database, and materialize results from the database result set. Re-enumerating the query may cause a new database round-trip, not just a re-run of in-memory operators. The exact translation behavior depends on the provider, but the core interview distinction is:
- LINQ to Objects: deferred execution means iterator logic runs when you enumerate.
- Database provider: the provider may translate the query into another language, then execute remotely.
For interviews, be precise: deferred execution does not mean “the result is always recalculated from scratch in every scenario.” It means the work is postponed until enumeration. If you materialize once, you create a stable snapshot in memory.
Failure modes
Common mistakes include:
- Assuming query definition equals query execution
- Building the query alone does not touch the source.
- Enumerating twice unintentionally
- For example, calling
Count()and then iterating again may traverse the source twice. - Putting side effects inside query operators
- Logging, counters, or mutations may happen more than expected.
- Assuming
IEnumerable<T>means cheap reuse - Reuse can still repeat expensive iteration.
- Confusing LINQ to Objects with database LINQ
- Database providers can translate and execute remotely, so the performance model is different.
Interview drill
Answer these aloud:
- When does a LINQ to Objects query execute?
- Why can enumerating it twice repeat work?
- How is that different from a database-backed LINQ provider?
- When would you call
ToList()before reusing a query? - Why is putting side effects in
WhereorSelectrisky?
A strong answer should mention deferred execution, repeated enumeration, materialization, and provider translation.
Revision checklist
- [ ] Query operators usually defer execution in LINQ to Objects.
- [ ] Enumeration triggers the actual work.
- [ ] Re-enumerating can re-run predicates, selectors, and source traversal.
- [ ]
ToList()materializes a snapshot and avoids repeating the pipeline. - [ ] Database providers may translate the query instead of executing it in memory.
- [ ] Side effects inside query operators are a bad idea.
Production code
The example below demonstrates deferred execution and repeated enumeration with a side effect counter. The query is defined once, but the work happens each time it is enumerated. The materialized list shows the difference between replaying the pipeline and reusing a snapshot.
Code walkthrough
The code starts by defining a source array and a counter. The counter is important because it proves how many times the predicate runs.
The query uses Where, but nothing happens yet. That is deferred execution.
When the first foreach runs, the source is traversed and the predicate runs once per source element. The matching values are yielded as the loop asks for them.
When the second foreach runs, the same pipeline is executed again. The query is the same variable, but it is not a stored result set. It is a reusable recipe.
The ToList() call changes the shape of the program. It forces enumeration immediately, stores the matching values, and lets you loop over the stored list repeatedly without rerunning the predicate.
That is the key interview answer: deferred execution delays work; repeated enumeration repeats work; materialization creates a stable snapshot.
Output: Deferred execution and repeated enumeration in LINQ to Objects
Query defined, but not yet executed.
Predicate calls so far: 0
First enumeration:
Filtering 1
Filtering 2
Result 2
Filtering 3
Filtering 4
Result 4
Predicate calls after first enumeration: 4
Second enumeration:
Filtering 1
Filtering 2
Result 2
Filtering 3
Filtering 4
Result 4
Predicate calls after second enumeration: 8
Materialize once with ToList:
Filtering 1
Filtering 2
Filtering 3
Filtering 4
Predicate calls while materializing: 4
Enumerate snapshot twice:
Snapshot item 2
Snapshot item 4
Snapshot item again 2
Snapshot item again 4
Snapshot scope
Materialization stores the selected entries, not deep copies of referenced objects. A later change to a shared mutable object can still be visible in both collections.
Executable code examples
Deferred execution and repeated enumeration in LINQ to Objects
Program.cs
using System;
using System.Collections.Generic;
using System.Linq;
var source = new[] { 1, 2, 3, 4 };
int predicateCalls = 0;
IEnumerable<int> query = source.Where(n =>
{
predicateCalls++;
Console.WriteLine($"Filtering {n}");
return n % 2 == 0;
});
Console.WriteLine("Query defined, but not yet executed.");
Console.WriteLine($"Predicate calls so far: {predicateCalls}");
Console.WriteLine("First enumeration:");
foreach (var item in query)
{
Console.WriteLine($"Result {item}");
}
Console.WriteLine($"Predicate calls after first enumeration: {predicateCalls}");
Console.WriteLine("Second enumeration:");
foreach (var item in query)
{
Console.WriteLine($"Result {item}");
}
Console.WriteLine($"Predicate calls after second enumeration: {predicateCalls}");
Console.WriteLine("Materialize once with ToList:");
predicateCalls = 0;
var snapshot = query.ToList();
Console.WriteLine($"Predicate calls while materializing: {predicateCalls}");
Console.WriteLine("Enumerate snapshot twice:");
foreach (var item in snapshot)
{
Console.WriteLine($"Snapshot item {item}");
}
foreach (var item in snapshot)
{
Console.WriteLine($"Snapshot item again {item}");
}