Skip to content

LINQ & Deferred Execution

Key Points

  • LINQ operators are lazy by default — chaining .Where().Select() builds an iterator; nothing executes until you enumerate (foreach, .ToList(), .First(), etc.).
  • Iterators allocate: Where allocates a wrapper object + a delegate. In hot paths these add up.
  • First() vs FirstOrDefault(): first throws on empty; default returns default(T). Use the right one.
  • Any() short-circuits; Count() > 0 materializes everything (or in LINQ-to-Entities, runs COUNT(*)).
  • LINQ-to-Objects runs against IEnumerable<T> (in-memory).
  • LINQ-to-Entities (or to-SQL, to-Cosmos) runs against IQueryable<T> — operators build expression trees, the provider translates to SQL/CQL/MongoDB query/etc.
  • .NET 9 added CountBy and AggregateBy — replace common GroupBy().Select(g => ...) patterns with allocation-free direct aggregates.
  • Deferred execution can hide bugs — exceptions appear at enumeration, not query construction.

Concepts (deep dive)

Deferred (lazy) execution

var query = customers.Where(c => c.IsActive).Select(c => c.Name);
// Nothing has executed yet. query is an IEnumerable<string>.

foreach (var name in query)   // executes here, one customer at a time
    Console.WriteLine(name);

foreach (var name in query)   // executes AGAIN — re-runs Where + Select
    Console.WriteLine(name);

LINQ operators are iterator-shaped: each operator wraps the source with an iterator that produces the next element on demand. This is the lazy/deferred execution model.

The implication: enumerating twice runs the query twice. If your source is expensive or stateful (e.g., a database query, a streaming reader), this is a bug. Materialize via .ToList(), .ToArray(), or .ToHashSet() if you intend to enumerate multiple times.

Materialization triggers

These force the query to execute now:

.ToList()          // List<T>
.ToArray()         // T[]
.ToHashSet()       // HashSet<T>
.ToDictionary(...) // Dictionary<K,V>
.ToLookup(...)     // ILookup<K,V>
.First() / .Single() / .Last()
.Count() / .LongCount()
.Any() / .All()
.Sum() / .Min() / .Max() / .Average()
.Aggregate(...)
.ElementAt(...)
foreach

Anything else (.Where(), .Select(), .OrderBy(), etc.) builds the iterator chain without executing.

Allocation cost

Each operator typically allocates: - An iterator object (wraps source + state). - A delegate (the lambda). - Possibly a closure if the lambda captures locals.

public int Count(int[] data, int min)
    => data.Where(x => x > min).Count();   // 2-3 allocations even for empty result

Mitigations for hot paths:

public int Count(int[] data, int min)
{
    int count = 0;
    foreach (var x in data) if (x > min) count++;
    return count;
}

The foreach loop is faster, allocation-free, and often as readable. Reserve LINQ for code clarity in non-critical paths; hand-roll in hot paths.

Any() vs Count() > 0

// ❌ Materializes everything (or runs COUNT(*) on the DB):
if (customers.Where(c => c.IsActive).Count() > 0) { ... }

// ✅ Stops at the first match:
if (customers.Any(c => c.IsActive)) { ... }

Any() short-circuits. For in-memory List<T>, the difference is small — but for paged DB queries or IAsyncEnumerable, it's huge.

First() family

// Throws InvalidOperationException if empty:
var c = customers.First(c => c.IsActive);

// Returns default(T) if empty:
var c = customers.FirstOrDefault(c => c.IsActive);

// Throws if empty OR more than one match:
var c = customers.Single(c => c.IsActive);

// Returns default(T) if empty; throws if more than one:
var c = customers.SingleOrDefault(c => c.IsActive);

Use the right one. Single()/SingleOrDefault() enumerate further to verify uniqueness — they don't stop at the first match.

💡 Senior insight: FirstOrDefault() for ref types returns null. With NRT, the result type is T? — flow analysis tracks the null possibility.

IEnumerable<T> vs IQueryable<T>

// IEnumerable: in-memory, runs LINQ-to-Objects
List<Customer> data = ...;
var active = data.Where(c => c.IsActive).ToList();   // each customer evaluated in C#

// IQueryable: deferred, builds an expression tree
IQueryable<Customer> dbCustomers = _db.Customers;
var active = dbCustomers.Where(c => c.IsActive).ToList();   // EF translates to SQL "WHERE IsActive = 1"

IQueryable<T>.Where(...) doesn't iterate — it captures the lambda as an Expression<Func<T, bool>> (an expression tree) and lets the provider translate. See Expression Trees.

⚠️ Mixing IEnumerable and IQueryable surprises: dbCustomers.AsEnumerable().Where(...) switches to LINQ-to-Objects mid-chain — the rest of the operators run in C#, not SQL. Often not what you want.

.NET 9:CountByandAggregateBy``

Instead of:

// Allocates an intermediate Lookup
var counts = customers
    .GroupBy(c => c.Country)
    .Select(g => (g.Key, Count: g.Count()))
    .ToDictionary(x => x.Key, x => x.Count);

Use:

var counts = customers.CountBy(c => c.Country);
// Returns IEnumerable<KeyValuePair<TKey, int>> — no GroupBy allocation

AggregateBy generalizes:

var totalsByCountry = customers.AggregateBy(
    keySelector: c => c.Country,
    seed: 0m,
    func: (acc, c) => acc + c.OrderTotal);

Pass-through allocation reduction; nice for hot aggregation paths.

Common LINQ-to-Entities translation gotchas

// ❌ EF Core can't translate Math.Round → SQL ROUND with banker's rounding
db.Items.Where(i => Math.Round(i.Price, 2) == amount);   // throws or runs in memory

// ❌ Can't translate user-defined methods
db.Items.Where(i => MyHelper(i));                        // throws

// ✅ Use translatable patterns or AsEnumerable past the SQL boundary
db.Items.Where(i => i.Price == amount).AsEnumerable().Where(...);

EF Core 8+ translates more; still, custom methods, complex object construction, and certain string/Math operations don't translate. Measure with logging or db.Customers.ToQueryString().

Eager loading vs deferred — the EF case

// IQueryable: nothing runs until enumerated.
var q = db.Customers.Where(c => c.IsActive);

// .Include forces a SQL JOIN:
var q2 = db.Customers.Include(c => c.Orders).Where(c => c.IsActive);

// AsNoTracking returns "free" entities — not change-tracked. Faster for read-only.
var q3 = db.Customers.AsNoTracking().Where(c => c.IsActive);

See EF Core — Querying & Projections.

Async LINQ

EF Core (and System.Linq.Async for in-memory) provide async terminators:

List<Customer> active = await db.Customers.Where(c => c.IsActive).ToListAsync(ct);
Customer? first = await db.Customers.FirstOrDefaultAsync(c => c.Id == id, ct);
int count = await db.Customers.CountAsync(c => c.IsActive, ct);

The provider executes the query asynchronously. Don't .ToList() then await separately — that's sync-over-async at the DB.

LINQ over IAsyncEnumerable<T>

// Requires System.Linq.Async NuGet
await foreach (var x in source.Where(x => x > 0).Take(10))
    Process(x);

Where, Select, Take, Skip for IAsyncEnumerable. Note: each operator is async-aware.


How it works under the hood

Where, Select, etc. on IEnumerable<T> return iterator objects that implement IEnumerable<T> and IEnumerator<T>. Pseudocode for Where:

public IEnumerable<T> Where<T>(this IEnumerable<T> source, Func<T, bool> predicate)
{
    foreach (var item in source)
        if (predicate(item))
            yield return item;
}

yield return makes the compiler generate a state machine. Each MoveNext advances the source until a predicate match.

IQueryable<T> operators are different: they build an expression tree that the provider translates to a query language. db.Customers.Where(c => c.Id == 5) doesn't enumerate — it stores the expression (c) => c.Id == 5. EF Core's IQueryProvider.Execute walks the tree and emits SELECT ... FROM Customers WHERE Id = 5.

LINQ-to-Objects operators are implemented in System.Linq.Enumerable — heavily optimized; some are specialized for T[], List<T>, etc. via internal IIListProvider and friends. .NET 9 added more pattern-based fast paths.


Code: correct vs wrong

❌ Wrong: enumerating multiple times unintentionally

IEnumerable<Customer> q = db.Customers.Where(c => c.IsActive);   // IQueryable
if (q.Any())
{
    foreach (var c in q) Process(c);   // runs the query again
}

✅ Correct: materialize once

var actives = await db.Customers.Where(c => c.IsActive).ToListAsync(ct);
if (actives.Count > 0)
    foreach (var c in actives) Process(c);

❌ Wrong: Count() > 0

if (customers.Count() > 0) ...   // walks the entire source

✅ Correct: Any()

if (customers.Any()) ...

❌ Wrong: assuming exception location

var query = source.Where(x => HelperThatThrows(x));
// No exception yet
foreach (var x in query)   // exception here, not at the Where line
    Process(x);

This is just how it is — but be aware when reading stack traces.

❌ Wrong: LINQ in a tight loop

public bool Contains(int[] arr, int value)
    => arr.Where(x => x == value).Any();   // allocates iterator + delegate

✅ Correct

public bool Contains(int[] arr, int value)
{
    foreach (var x in arr) if (x == value) return true;
    return false;
}

(Or simply: Array.IndexOf(arr, value) >= 0.)

❌ Wrong: mixing IQueryable and IEnumerable accidentally

var q = db.Customers
    .Where(c => c.IsActive)
    .ToList()   // ❌ materializes ALL active customers
    .Where(c => c.OrderCount > 5);   // filter in memory

✅ Correct: keep filtering in SQL

var q = db.Customers
    .Where(c => c.IsActive && c.OrderCount > 5)
    .ToList();

Design patterns for this topic

Pattern 1 — "Materialize when consumed multiple times"

  • Intent: avoid re-execution of the source.
  • Code sketch: var list = query.ToList(); foreach (...) ...; foreach (...) ...;

Pattern 2 — "Use Any() over Count() > 0"

  • Intent: short-circuit.

Pattern 3 — "Hand-roll loops in hot paths"

  • Intent: zero-allocation, JIT-friendly.

Pattern 4 — "Use CountBy/AggregateBy for grouping aggregates"

  • Intent: avoid GroupBy allocations (.NET 9+).

Pattern 5 — "Compose IQueryable for reusability"

  • Intent: build domain queries incrementally.
  • Code sketch:
public static class CustomerQueries
{
    public static IQueryable<Customer> Active(this IQueryable<Customer> q)
        => q.Where(c => c.IsActive);

    public static IQueryable<Customer> InCountry(this IQueryable<Customer> q, string country)
        => q.Where(c => c.Country == country);
}

var us = await db.Customers.Active().InCountry("US").ToListAsync();

Pros & cons / trade-offs

Aspect Pros Cons
Deferred execution Composable; lazy Hidden re-execution; deferred exceptions
LINQ-to-Objects Concise; readable Allocations per operator
LINQ-to-Entities Translates to SQL Limited operator translation
CountBy/AggregateBy Allocation-free aggregates .NET 9+
LINQ over IAsyncEnumerable Streaming Higher per-item overhead

When to use / when to avoid

  • Use LINQ for readability in non-hot paths.
  • Use IQueryable composition for reusable database query fragments.
  • Avoid LINQ in measured hot paths — prefer plain loops.
  • Avoid Count() > 0 — use Any().
  • Avoid mixing IQueryable and IEnumerable by accident — be explicit about where the database boundary is.

Interview Q&A

Q1. What does deferred execution mean in LINQ? LINQ operators return iterators that don't execute until enumerated. .Where() doesn't run the predicate; the predicate runs when you foreach or call .ToList()/.First()/etc.

Q2. What's the difference between IEnumerable<T> and IQueryable<T>? IEnumerable<T> is in-memory iteration; LINQ-to-Objects. IQueryable<T> builds expression trees; the provider translates to a query language (SQL, etc.).

Q3. Why is Any() better than Count() > 0? Any() short-circuits at the first match. Count() walks the entire source (or runs SELECT COUNT(*)). For paged or async sources, the difference is significant.

Q4. What's the difference between First(), Single(), and their OrDefault variants? First returns the first matching element; throws if none. Single requires exactly one match; throws if zero or many. *OrDefault returns default(T) instead of throwing on zero.

Q5. Why does Single() enumerate past the first match? To verify uniqueness — it must check that no second element matches. This is why First is faster than Single when "first match" is what you really want.

Q6. What's CountBy (.NET 9)? source.CountBy(keySelector) returns IEnumerable<KeyValuePair<TKey, int>> of element counts grouped by key — without the GroupBy().Select() allocation overhead.

Q7. When does Where().Select() execute? At enumeration. The chain is purely setup until foreach, .ToList(), etc.

Q8. How does EF Core translate LINQ? Walks the expression tree, recognizes patterns, emits provider-specific SQL. Some operations don't translate (custom methods, certain math) — fail at runtime or run in memory after warning.

Q9. Why might query.ToList() followed by .Where(...) be wasteful? ToList() materializes the entire unfiltered result; the second Where filters in memory. Better: combine into a single chained query so the database does the filter.

Q10. Are LINQ exceptions caught at query construction or at enumeration? At enumeration. var q = source.Select(x => Throw()) doesn't throw; foreach (var x in q) does.

Q11. What's the cost of allocating a Where clause in a hot loop? 2-3 allocations per call (iterator wrapper, possibly delegate, possibly closure for captured variables). In a loop running 10⁶ times, that's measurable; replace with a plain loop.

Q12. How does IAsyncEnumerable<T> LINQ differ from sync LINQ? Each operator is asyncMoveNextAsync returns ValueTask<bool>. Operators in System.Linq.Async (sometimes shipped under different names like await foreach (var x in source.WhereAwait(...))) handle async predicates.


Gotchas / common mistakes

  • ⚠️ Multiple enumeration — re-runs the query.
  • ⚠️ Count() > 0 — full scan.
  • ⚠️ AsEnumerable() mid-IQueryable chain — rest runs in memory.
  • ⚠️ Custom method in Where on IQueryable — translation fails.
  • ⚠️ Single() for "first or fail" — actually requires unique match.
  • ⚠️ Closure captures in hot LINQ — allocation per call.
  • ⚠️ Forgetting await on async terminators — synchronous DB call.

Further reading