Immediate execution in LINQ: scalar and collection operators, First versus Single versus Any, choosing the right materialisation, async materialisation in EF Core, and allocation costs.
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.
Immediate execution operators consume the sequence when you call them: the To* materialisers, aggregates (Count, Sum, Min, Max, Average, Aggregate), quantifiers (Any, All, Contains) and element operators (First, Single, Last, ElementAt and their OrDefault versions). I'd explain how to choose between the materialisers, the cost of each, the single-item operators, and how to materialise efficiently — including the async EF Core versions.
| Operator | Produces | Use when | Cost |
|---|---|---|---|
ToList() |
List<T> |
You'll add/remove later, or just need a general collection | O(n); may over-allocate (growth) unless size is known |
ToArray() |
T[] |
Fixed results, smallest footprint, span-friendly | O(n); exact size (uses size if known, otherwise grows then copies once) |
ToDictionary(k, v) |
Dictionary<K,V> |
Lookup by unique key | O(n); throws on duplicate keys |
ToHashSet() |
HashSet<T> |
Fast membership checks, de-duplication | O(n) |
ToLookup(k) |
ILookup<K,T> |
One-to-many grouping, immutable, missing keys return empty | O(n) |
ToFrozenDictionary() / ToFrozenSet() (.NET 8) |
Frozen collections | Built once, read many times (config, static lookups) | Slower to build, fastest to read |
ToImmutableArray() etc. |
Immutable collections | Sharing snapshots safely | O(n) |
Always pass a comparer for string keys: ToDictionary(c => c.Email, StringComparer.OrdinalIgnoreCase).
For duplicates, decide the policy: GroupBy(...).ToDictionary(g => g.Key, g => g.First()), DistinctBy(k).ToDictionary(...), or ToLookup if multiple values are valid.
With IQueryable<T> from EF Core, use the async terminal operators so the database round trip doesn't block a thread:
var orders = await db.Orders.Where(o => o.CustomerId == id).ToListAsync(ct);
var byId = await db.Products.ToDictionaryAsync(p => p.Sku, ct);
var exists = await db.Orders.AnyAsync(o => o.Id == orderId, ct);
var count = await db.Orders.CountAsync(o => o.Status == Pending, ct);
For IAsyncEnumerable<T>, .NET 10 includes async LINQ operators (ToListAsync, etc.) in System.Linq.AsyncEnumerable.
| Operator | Empty | One | Many | SQL (EF Core) |
|---|---|---|---|---|
First() |
Throws | Returns it | Returns first | TOP(1) |
FirstOrDefault() |
default |
Returns it | Returns first | TOP(1) |
Single() |
Throws | Returns it | Throws | TOP(2) (to detect duplicates) |
SingleOrDefault() |
default |
Returns it | Throws | TOP(2) |
Last() |
Throws | Returns it | Returns last | Needs OrderBy; EF translates by reversing order |
Any() |
false | true | true | EXISTS |
Count() |
0 | 1 | n | COUNT(*) |
Guidance:
Single when more than one result is a data integrity bug (lookup by unique key) — it fails loudly. Use First when you just want one (with an explicit OrderBy — "first" without ordering is arbitrary in SQL).Any() instead of Count() > 0 — Any stops at the first match (EXISTS), Count scans everything.List<T>/arrays, Count property is O(1); Count() extension is O(1) for ICollection<T> but O(n) for lazy sequences.FirstOrDefault(predicate, defaultValue) (.NET 6) avoids ambiguity with value types.db.Orders.Where(...).Select(o => new OrderDto(...)).ToListAsync() — not ToList() then filter.ToArray/ToList already use known counts (TryGetNonEnumeratedCount).await foreach (var o in db.Orders.AsAsyncEnumerable()) for exports.ToHashSet() before membership checks in loops; ToDictionary before repeated lookups.ToList() just to call ForEach — use foreach.An import job called existing = db.Customers.ToList() (300,000 rows, tracked) then existing.FirstOrDefault(c => c.Email == row.Email) for each of 50,000 import rows — 15 billion comparisons and gigabytes of tracked entities. We changed it to var byEmail = await db.Customers.AsNoTracking().Select(c => new { c.Id, c.Email }).ToDictionaryAsync(c => c.Email, c => c.Id, StringComparer.OrdinalIgnoreCase, ct); and O(1) lookups. The job went from ~40 minutes to under a minute.
Checks that the candidate knows when queries run and can pick the right operator and collection.
Green flags: lists immediate operators; Any versus Count; First versus Single with SQL shapes; ToDictionary versus ToLookup; frozen collections; async in EF Core.
Red flags: thinks Where executes; uses Count() > 0; never heard of ToLookup; sync EF calls in web apps.
ToList/ToArray size their buffersICollection<T>, ToList/ToArray call CopyTo with an exact-size buffer — one allocation.IIListProvider<T>/Select over a list), it pre-sizes too.ToArray uses a growing builder (doubling segments, then a final exact copy); ToList uses List<T> growth (may end with spare capacity).Enumerable.TryGetNonEnumeratedCount (.NET 6) exposes whether a count is available without enumerating.ToDictionary vs ToLookup vs GroupByToDictionary enforces uniqueness (throws ArgumentException with the duplicate key).ToLookup is an immutable one-to-many map; indexing a missing key returns an empty sequence (no exception).GroupBy is deferred; each enumeration regroups.Single semantics in SQLEF Core translates SingleOrDefault to SELECT TOP(2) and throws if two rows come back. That makes it slightly more expensive than FirstOrDefault (TOP(1)), but it's the right choice for unique lookups where duplicates mean corrupted data. For primary keys, FindAsync(id) also checks the change tracker first (no query if already tracked).
Count() vs Count vs Any()list.Count // property, O(1)
list.Count() // extension, detects ICollection<T>, O(1)
query.Count() // enumerates or COUNT(*) in SQL
query.Any() // stops at first element / EXISTS
CA1827/CA1829/CA1860 analysers suggest Any(), Count property, and Length/Count comparisons instead of Count().
Sum() of an empty sequence is 0.Min()/Max()/Average() on an empty sequence of non-nullable value types throw InvalidOperationException; on nullable types they return null. In EF Core, Max on an empty set translates to SQL returning NULL — if the result type isn't nullable, materialisation throws. Use .Select(x => (decimal?)x.Total).Max() or DefaultIfEmpty.ToList) and async (ToListAsync) on IQueryable — sync blocks a thread on I/O.ToListAsync on an IEnumerable (not IQueryable) doesn't compile unless it's an IAsyncEnumerable.AsAsyncEnumerable() streams but keeps the connection open while iterating — do work quickly or buffer.Count() > 0 vs Any() on a lazy Where over 1 million items.ToDictionary on data with duplicate keys and read the exception; fix with DistinctBy or ToLookup.First, Single and Any with ToQueryString().Max() on an empty EF query of decimal and fix it with a nullable projection.using System.Collections.Frozen;
namespace Linq.Immediate;
public sealed record Product(string Sku, string Category, decimal Price);
public static class Program
{
public static void Main()
{
List<Product> products =
[
new("A1", "Tools", 20m),
new("B2", "Garden", 35m),
new("C3", "Tools", 15m),
];
// Scalars and quantifiers
Console.WriteLine(products.Any(p => p.Price > 30m)); // stops at B2
Console.WriteLine(products.Count(p => p.Category == "Tools")); // full scan
Console.WriteLine(products.Sum(p => p.Price));
// Element operators
var firstTool = products.First(p => p.Category == "Tools");
var maybeShed = products.FirstOrDefault(p => p.Category == "Sheds"); // null, no exception
var b2 = products.Single(p => p.Sku == "B2"); // exactly one expected
// Conversions chosen by usage
Product[] snapshot = products.Where(p => p.Price > 10m).ToArray();
Dictionary<string, Product> bySku = products.ToDictionary(p => p.Sku); // throws on duplicate SKU
ILookup<string, Product> byCategory = products.ToLookup(p => p.Category); // one-to-many
HashSet<string> categories = products.Select(p => p.Category).ToHashSet();
FrozenSet<string> blockedSkus = new[] { "Z9", "Y8" }.ToFrozenSet(); // build once, read often
Console.WriteLine($"{firstTool.Sku} {maybeShed is null} {b2.Price} {snapshot.Length}");
Console.WriteLine($"{bySku["C3"].Price} {byCategory["Tools"].Count()} {byCategory["Sheds"].Count()}");
Console.WriteLine($"{categories.Contains("Garden")} {blockedSkus.Contains("A1")}");
try { products.ToDictionary(p => p.Category); }
catch (ArgumentException) { Console.WriteLine("Duplicate key: use ToLookup or GroupBy instead"); }
}
}
// EF Core equivalents (always async in ASP.NET Core):
// var exists = await db.Products.AnyAsync(p => p.Sku == sku, ct);
// var product = await db.Products.SingleOrDefaultAsync(p => p.Sku == sku, ct);
// var list = await db.Products.Where(p => p.Category == c).AsNoTracking().ToListAsync(ct);
Count() > 0 instead of Any()What happens: counting all items to check existence.
Why it is wrong: scans everything (or COUNT(*) in SQL).
Do instead: Any().
What happens: db.Orders.ToList().Where(...).
Why it is wrong: loads the whole table.
Do instead: filter and project on IQueryable, then materialise.
First without OrderByWhat happens: db.Orders.First(o => o.CustomerId == id) expecting the latest order.
Why it is wrong: SQL row order is undefined without ORDER BY.
Do instead: add OrderByDescending(o => o.CreatedAt).
First where Single is the ruleWhat happens: unique lookups with FirstOrDefault.
Why it is wrong: silently hides duplicate data.
Do instead: SingleOrDefault (or a unique index + FirstOrDefault deliberately).
ToDictionary without handling duplicatesWhat happens: building dictionaries from external data.
Why it is wrong: throws on the first duplicate key.
Do instead: decide a duplicate policy or use ToLookup.
What happens: list.FirstOrDefault(x => x.Id == id) per item.
Why it is wrong: O(n × m).
Do instead: ToDictionary/ToHashSet once.
What happens: ToList() on EF queries in ASP.NET Core.
Why it is wrong: blocks thread-pool threads on I/O.
Do instead: ToListAsync(ct).
What happens: Max() on possibly empty results.
Why it is wrong: throws for non-nullable types.
Do instead: project to nullable, use DefaultIfEmpty, or check Any first.
Materialisation is where a lazy query turns into real cost: I/O, CPU and memory. Seniors choose the operator for the question being asked (exists? exactly one? lookup by key?) and the collection for how it will be used next. In EF Core, I check the generated SQL for TOP(1), TOP(2) and EXISTS, use async versions with cancellation, and avoid materialising more rows or columns than the caller needs.
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.
Immutable collections (System.Collections.Immutable) never change after creation; "modifying" methods such as Add return a new collection and leave the old one untouched, so they are safe to share across threads without locks. ImmutableArray<T> is a struct around a plain array: reads are as fast as an array, but every Add copies the whole thing (O(n)). ImmutableList<T> and ImmutableDictionary<TKey,TValue> are balanced trees that share structure: an Add is O(log n) and reuses most of the old nodes, but reads are slower. Use builders (ToBuilder() ... ToImmutable()) for many changes. A read-only view like ReadOnlyCollection<T> or IReadOnlyList<T> only stops you changing it; the underlying list can still change. FrozenDictionary/FrozenSet (.NET 8+) are immutable too but optimised purely for fast reads with no "modified copy" operations.
Last updated 30 Oct 2026.