📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Which LINQ operators cause immediate execution, and how do you choose between ToList, ToArray, ToDictionary, ToHashSet, ToLookup and the async versions? Explain the cost of each, single-item operators like First, Single and Any, and how to materialise efficiently.

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.

Junior Theory Intermediate csharp immediate-execution linq materialisation performance

🎯 Key points

  • Immediate operators run the query when called: aggregates (Count, Sum, Min, Max, Average, Aggregate), element operators (First, Single, Last, ElementAt), quantifiers (Any, All, Contains), and conversions (ToList, ToArray, ToDictionary, ToHashSet, ToLookup)
  • `Any()` stops at the first element; `Count() > 0` may scan everything (or translate to COUNT in SQL)
  • `First` throws on empty, `FirstOrDefault` returns default; `Single` also throws if there is more than one and must read a second item
  • ToList versus ToArray: similar cost in modern .NET; pick by what the consumer needs (mutable list versus fixed array)
  • ToDictionary throws on duplicate keys; ToLookup allows many values per key and is immutable
  • ToHashSet for fast Contains; ToFrozenSet/ToFrozenDictionary for read-heavy, build-once data
  • With EF Core use the async versions (ToListAsync, FirstOrDefaultAsync, AnyAsync) and pass a CancellationToken

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Choosing a materialiser

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.

2. Async versions (EF Core and async streams)

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.

3. Single-item operators

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:

  • Use 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).
  • Use Any() instead of Count() > 0 — Any stops at the first match (EXISTS), Count scans everything.
  • On 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.

4. Materialising efficiently

  • Filter and project first, then materialise: db.Orders.Where(...).Select(o => new OrderDto(...)).ToListAsync() — not ToList() then filter.
  • Materialise once and reuse (avoid multiple enumeration).
  • Pre-size when you build collections manually; LINQ's ToArray/ToList already use known counts (TryGetNonEnumeratedCount).
  • Stream instead of materialising huge results: await foreach (var o in db.Orders.AsAsyncEnumerable()) for exports.
  • Use ToHashSet() before membership checks in loops; ToDictionary before repeated lookups.
  • Avoid ToList() just to call ForEach — use foreach.

5. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. How ToList/ToArray size their buffers

  • If the source implements ICollection<T>, ToList/ToArray call CopyTo with an exact-size buffer — one allocation.
  • If the source is a LINQ iterator that knows its count (IIListProvider<T>/Select over a list), it pre-sizes too.
  • Otherwise, 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.

2. ToDictionary vs ToLookup vs GroupBy

  • ToDictionary 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.

3. Single semantics in SQL

EF 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).

4. 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().

5. Aggregates on empty sequences

  • 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.

6. Async materialisation pitfalls

  • Mixing sync (ToList) and async (ToListAsync) on IQueryable — sync blocks a thread on I/O.
  • Calling 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.

Try it yourself

  1. Benchmark Count() > 0 vs Any() on a lazy Where over 1 million items.
  2. Call ToDictionary on data with duplicate keys and read the exception; fix with DistinctBy or ToLookup.
  3. Compare SQL for First, Single and Any with ToQueryString().
  4. Call Max() on an empty EF query of decimal and fix it with a nullable projection.

💻 Code

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);

⚠ Common mistakes

1. 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().

2. Materialising before filtering

What happens: db.Orders.ToList().Where(...). Why it is wrong: loads the whole table. Do instead: filter and project on IQueryable, then materialise.

3. First without OrderBy

What 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).

4. First where Single is the rule

What happens: unique lookups with FirstOrDefault. Why it is wrong: silently hides duplicate data. Do instead: SingleOrDefault (or a unique index + FirstOrDefault deliberately).

5. ToDictionary without handling duplicates

What 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.

6. Lookups in lists inside loops

What happens: list.FirstOrDefault(x => x.Id == id) per item. Why it is wrong: O(n × m). Do instead: ToDictionary/ToHashSet once.

7. Sync materialisation in async code

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).

8. Aggregates on empty sequences

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate lists operators that execute immediately. Why: Immediate operators are the counterpart of deferred execution.
    How to answer

    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.

  2. When: Candidate mentions ToFrozenSet or ToFrozenDictionary. Why: Frozen and immutable collections are materialisation targets with different trade-offs.
    How to answer

    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.

↩ Usually asked after

More in LINQ

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me