Deferred execution in practice: when queries run, captured variables, multiple enumeration, disposed contexts, late exceptions, and the tools and rules that prevent these bugs.
Deferred execution means a LINQ query is a recipe that runs each time you enumerate it, not when you write it. That explains all four symptoms. The "changing" list is a query that re-reads its source and captured variables. The ObjectDisposedException comes from enumerating an EF query after the DbContext was disposed. Three log lines are three enumerations, and the late exception is a lambda failing during enumeration. I fix them by materialising once with ToList at a clear boundary inside the context's scope, and by turning on CA1851 for multiple enumeration.
All four symptoms come from deferred execution: a LINQ query is a recipe, not a result. It runs when it's enumerated, every time it's enumerated, against whatever state exists at that moment. I'd explain the concept and then debug each symptom.
Where, Select, OrderBy etc. return an IEnumerable<T>/IQueryable<T> that describes the computation. Nothing happens until something enumerates it: foreach, ToList, Count, First, Any, serialisation, data binding, the debugger's "Results View". Each enumeration re-runs the whole pipeline from the source.
var active = customers.Where(c => c.IsActive); // not a list - a query
customers.Add(new Customer { IsActive = true });
Console.WriteLine(active.Count()); // includes the new customer
The variable holds a query over customers; enumerating later sees later changes. Same with captured variables:
var minTotal = 100m;
var big = orders.Where(o => o.Total > minTotal);
minTotal = 1000m; // query now uses 1000
Fix: materialise when you need a snapshot — var active = customers.Where(c => c.IsActive).ToList(); — and avoid mutating captured variables after building queries.
ObjectDisposedException from a database querypublic IEnumerable<Order> GetOrders(Guid customerId)
{
using var db = _factory.CreateDbContext();
return db.Orders.Where(o => o.CustomerId == customerId); // returned unexecuted
} // db disposed here
foreach (var o in GetOrders(id)) ... // executes now -> ObjectDisposedException
The query executes after the DbContext was disposed. Same pattern with yield return inside a using, or returning IQueryable from a scoped repository and enumerating after the request scope ended.
Fix: materialise inside the scope: return await db.Orders.Where(...).ToListAsync(ct); and return IReadOnlyList<Order> (or DTOs).
var pending = db.Orders.Where(o => o.Status == OrderStatus.Pending);
if (pending.Any()) // query 1: SELECT CASE WHEN EXISTS...
{
_logger.LogInformation("{Count} pending", pending.Count()); // query 2: SELECT COUNT(*)
foreach (var o in pending) Process(o); // query 3: SELECT ...
}
Each terminal operation re-executes the query. With LINQ to Objects, it re-runs filters and projections (and any side effects); with EF Core, it hits the database each time — and results may differ between executions.
Fix: materialise once: var pending = await db.Orders.Where(...).ToListAsync(ct); if (pending.Count > 0) .... Use analyser CA1851 (possible multiple enumeration) and EF Core logging to detect it.
var parsed = lines.Select(l => int.Parse(l.Split(',')[2])); // line 10: no error yet
...
var total = parsed.Sum(); // line 80: FormatException here
The lambda runs during enumeration, so the stack trace points at Sum() (with the lambda frame inside) rather than where the query was defined. Similarly, iterator methods with argument validation inside the yield body throw only when enumerated.
Fix: materialise at the boundary where the data is produced (ToList() right after parsing), validate eagerly (separate argument checks from iterator bodies using local functions — as the BCL does), and handle bad data explicitly (TryParse).
IEnumerable/IQueryable means "query", List/array means "data".IEnumerable<T> from scoped resources.A report service returned IEnumerable<InvoiceDto> built from an EF query inside a using block. It worked in unit tests (in-memory list) but threw ObjectDisposedException in production. After fixing that by moving ToList() inside, logs showed the report's main query executing three times (Any, Count, foreach) — tripling database load at month-end. Materialising once fixed both and cut report time by ~60%.
Checks practical mastery of deferred execution through realistic symptoms.
Green flags: maps each symptom to its cause; materialisation boundaries; DbContext lifetime; CA1851; side-effect-free lambdas; mentions Results View.
Red flags: "just add ToList everywhere" without reasoning; does not know queries re-run; blames EF Core for disposal bugs.
GetEnumerator() afresh each time, creating new iterator state and re-reading the source.EntityQueryable<T> holding an expression tree and the provider (which references the DbContext). Enumeration triggers translation (cached) and a database round trip.| Enumerates (executes) | Doesn't enumerate |
|---|---|
foreach, ToList, ToArray, ToDictionary, ToHashSet, ToLookup |
Where, Select, OrderBy, Skip, Take, Concat, Distinct, GroupBy* |
Count, Any, All, Contains, First, Single, Last, ElementAt, Min, Max, Sum, Average, Aggregate |
AsEnumerable, AsQueryable, Cast, OfType |
string.Join(", ", query), serialisation, data binding, List<T>(query) constructor |
Assigning to a variable |
* GroupBy/OrderBy are deferred, but buffer the whole source on first enumeration.
OrderBy(_ => Guid.NewGuid()), DateTime.Now in a lambda, database changes).IAsyncEnumerable from a network stream, File.ReadLines re-opens the file each time, a Channel reader) — second enumeration fails or yields nothing.public static IEnumerable<T> TakeEvery<T>(this IEnumerable<T> source, int step)
{
ArgumentNullException.ThrowIfNull(source); // runs at call time
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(step);
return Impl(source, step);
static IEnumerable<T> Impl(IEnumerable<T> s, int st) { int i = 0; foreach (var x in s) if (i++ % st == 0) yield return x; }
}
This is how the BCL's LINQ operators validate arguments immediately while executing lazily.
IEnumerable<T> within a method.DbCommandInterceptor executions) catch query multiplication.using var db and enumerate outside.Select lambda and call Any, Count and foreach on the same query.int.Parse inside a Select, define the query, and enumerate it later; inspect the stack trace.namespace Linq.Deferred;
public sealed record Customer(int Id, string Name, decimal Balance);
public static class Program
{
public static void Main()
{
List<Customer> customers = [new(1, "Ava", 50m), new(2, "Noah", 500m)];
var minimum = 100m;
var evaluations = 0;
IEnumerable<Customer> rich = customers.Where(c =>
{
evaluations++; // side effect for demonstration only
return c.Balance > minimum;
});
// Symptom 1: the "list" changes
customers.Add(new Customer(3, "Mia", 900m));
minimum = 40m;
Console.WriteLine(string.Join(",", rich.Select(c => c.Name))); // Ava, Noah, Mia
// Symptom 3: multiple enumeration
if (rich.Any())
{
Console.WriteLine(rich.Count());
var list = rich.ToList();
}
Console.WriteLine($"Predicate ran {evaluations} times");
// Fix: materialise once at a clear boundary
evaluations = 0;
IReadOnlyList<Customer> snapshot = customers.Where(c => c.Balance > minimum).ToList();
if (snapshot.Count > 0) Console.WriteLine(snapshot.Count);
// Symptom 4: exception appears at enumeration time
IEnumerable<int> lengths = customers.Select(c => c.Name.Length / (c.Id - 2)); // divide by zero for Id 2
try
{
foreach (var l in lengths) Console.WriteLine(l);
}
catch (DivideByZeroException)
{
Console.WriteLine("Thrown during enumeration, not at the Select line");
}
}
}
// Symptom 2 (EF Core), shown as a pattern:
// public IQueryable<Order> GetOrders() { using var db = _factory.CreateDbContext(); return db.Orders.Where(...); } // BUG
// public async Task<IReadOnlyList<Order>> GetOrdersAsync(CancellationToken ct)
// {
// await using var db = await _factory.CreateDbContextAsync(ct);
// return await db.Orders.Where(...).AsNoTracking().ToListAsync(ct); // FIX
// }
What happens: methods return IEnumerable/IQueryable built over a disposed DbContext or closed file.
Why it is wrong: ObjectDisposedException or reading closed resources.
Do instead: materialise inside the scope and return concrete read-only results.
What happens: Any(), Count(), then foreach on one query.
Why it is wrong: repeated work or database queries, possibly inconsistent results.
Do instead: materialise once.
What happens: treating a query variable as a fixed list.
Why it is wrong: later source changes and captured variable changes affect results.
Do instead: ToList() when you need a snapshot.
What happens: logging, counting or mutating state in Select/Where.
Why it is wrong: runs per enumeration, possibly multiple times or partially.
Do instead: keep lambdas pure.
What happens: inspecting a query in the debugger.
Why it is wrong: executes the query (and may change state with side-effecting lambdas).
Do instead: inspect materialised variables or SQL via ToQueryString().
What happens: argument checks inside a yield method body.
Why it is wrong: errors surface far from the bad call.
Do instead: validate eagerly and delegate to a local iterator function.
What happens: ToList() after every operator "to be safe".
Why it is wrong: wasted memory and CPU; for EF, breaks SQL translation by moving filters into memory.
Do instead: materialise once at the boundary, after composing filters.
What happens: DateTime.Now or random values inside lambdas.
Why it is wrong: each enumeration produces different results.
Do instead: capture values into locals before building the query, then materialise.
Deferred execution is a feature: it lets you compose queries and lets EF Core push work into SQL. The senior skill is choosing a clear materialisation boundary, usually at the edge of the service or repository method, so that database work happens where you can see, time and trace it. I enable CA1851, keep IQueryable inside the data layer, and teach the team the four symptoms because they explain most "LINQ is weird" bug reports.
Any operator that returns a value or a concrete collection runs the query immediately: Count, Sum, First, Single, Any, Contains, and the conversions ToList, ToArray, ToDictionary, ToHashSet and ToLookup. I use Any rather than Count() > 0 because it stops at the first match, and Single only when more than one result really is a bug. For conversions I choose by what the consumer needs: a list or array to iterate, a dictionary or hash set for lookups, a lookup for one-to-many. With EF Core I always use the async versions with a cancellation token.
IEnumerable<T> is just a promise that you can get an enumerator. IEnumerator<T> gives MoveNext, Current and Dispose, and that is all: no count, no indexing, and possibly lazy, infinite or single-use. foreach is pattern-based and compiles to a while (MoveNext()) loop with Dispose in a finally. List<T> uses a struct enumerator, which gets boxed if you iterate through the interface. IAsyncEnumerable<T> is the async version, used with await foreach. I accept IEnumerable<T> when I only iterate once, and return IReadOnlyList<T> unless I mean to stream.
IQueryable<T> is an IEnumerable<T> that also carries an expression tree and a provider. Queryable.Where takes an Expression<Func<T,bool>> and just wraps the expression, and when you enumerate, the provider, for example EF Core, translates the whole tree, typically into SQL. IEnumerable operators take delegates and run in memory. Which one you get depends on the static type of the variable, so storing a DbSet in an IEnumerable variable quietly moves every later Where into memory. AsEnumerable() does that on purpose. I keep IQueryable inside the data layer and return materialised results.
How to tell it (STAR)
**S:** A reporting endpoint failed intermittently with disposed-context errors. **T:** Find the cause. **A:** I showed with a breakpoint that the query only ran during JSON serialisation, after the context was gone, because of deferred execution. I materialised inside the scope and added an architecture test. **R:** The errors stopped and query timings became visible in our traces.
Last updated 30 Oct 2026.