LINQ is the part of C# that every developer uses within their first week and few developers fully understand after ten years, because its surface syntax hides two genuinely different execution models: compiled delegates running in your process, and expression trees that a provider such as Entity Framework Core translates into SQL. Senior interviews in this area probe whether you know which model a given line of code uses, when a query actually runs, and where the two models' boundary produces real production bugs: multiple enumeration, silent client-side evaluation, and query plan cache pollution from unparameterized literals. The ten questions below cover deferred versus immediate execution, IQueryable<T> translation, building and compiling expression trees by hand, the multiple-enumeration trap, EF Core's client-versus-server rules, and where LINQ costs more than a plain loop. Expect to reason about what runs where and when, not just what a method does.
Q1 What is the difference between deferred and immediate LINQ execution, and between streaming and non-streaming deferred operators?#
Short answer: Immediate operators, such as ToList, Count() and First(), run the moment you call them and return a value or a materialized collection. Deferred operators, such as Where and OrderBy, return a sequence and do nothing until something enumerates it, and deferred operators split further into streaming ones, like Where and Select, which produce elements one at a time, and non-streaming ones, like OrderBy and GroupBy, which must read the entire source before yielding a first result.
Deferred execution is what makes LINQ composable without intermediate collections: a chain of Where, Select and Take is a chain of decorators around the original sequence, and MoveNext pulls one element through the whole chain per call. It becomes a bug source when a query captures a variable that changes before enumeration, when the source is a DbContext that gets disposed before the query runs, or when an exception you expected "at the Where line" actually surfaces later, at the foreach.
decimal threshold = 100m;
List<decimal> totals = [50m, 120m, 300m];
IEnumerable<decimal> large = totals.Where(t => t > threshold); // nothing runs yet
threshold = 200m; // the lambda captured the variable, not its value
totals.Add(500m); // read at enumeration time, not declaration time
Console.WriteLine(string.Join(", ", large)); // 300, 500What interviewers look for: the streaming-versus-non-streaming distinction stated correctly, since it explains why OrderBy(...).First() still has to scan the whole source in the general case, and a concrete example of a bug deferred execution caused.
- Common mistakes: assuming all deferred operators are equally cheap to chain; a non-streaming operator like
GroupBybuffers everything before producing its first group. - Follow-up questions: Why does
OrderBy(...).First()inSystem.Linqavoid a full sort in practice even thoughOrderByis non-streaming? (The implementation recognizes the shape and performs a single linear scan instead of sorting the whole sequence.)
Q2 How does IQueryable<T> differ from IEnumerable<T> at the type level, and how does EF Core turn a query into SQL?#
Short answer: Enumerable's operators take Func<T, TResult> delegates and run in your process; Queryable's operators, which IQueryable<T> exposes, take Expression<Func<T, TResult>> arguments, so the compiler builds a data structure describing your lambda instead of compiling it to IL. A provider such as EF Core walks that tree when the query executes and translates it into SQL, which is why the exact same lambda syntax can mean "run in memory" or "become a WHERE clause" depending only on the static type of the source it is applied to.
Overload resolution picks the Queryable or Enumerable version based on the compile-time type, not the run-time type, which is why one careless AsEnumerable() in the middle of a query silently switches every following operator to LINQ to Objects, pulling all matching rows into memory before filtering them further.
// IQueryable<Order>: the whole pipeline becomes one SQL statement.
var recent = await db.Orders
.Where(o => o.CustomerId == customerId && o.PlacedOn >= since)
.OrderByDescending(o => o.PlacedOn)
.Select(o => new OrderSummary(o.Id, o.Total))
.ToListAsync(ct);
// AsEnumerable switches to LINQ to Objects: every matching-by-nothing row loads first.
var slow = db.Orders.AsEnumerable().Where(o => o.CustomerId == customerId).ToList();What interviewers look for: the expression-tree-versus-delegate distinction as the actual mechanism, not "IQueryable is for databases," plus recognition of AsEnumerable() as the specific place client evaluation sneaks in.
- Common mistakes: believing
IQueryable<T>always means "database"; any provider, including in-memory test doubles and OData clients, can implement it. - Follow-up questions: What would happen if you called a method EF Core cannot translate inside a
WhereonIQueryable<Order>? (It throws a translation exception at run time, unless it appears only in the final projection, where client evaluation is still permitted.)
Q3 How would you build an expression tree dynamically at run time, for example to implement a generic "sort by property name" API?#
Short answer: Use the System.Linq.Expressions factory methods, Expression.Parameter, Expression.Property and Expression.Lambda, to construct the key selector by hand, then invoke Queryable.OrderBy or OrderByDescending through reflection with that lambda wrapped in Expression.Quote. This is exactly how generic "sort by column name" and "filter by field name" APIs work when the property is only known as a string at run time, for example from a query-string parameter.
Because the property name comes from outside the code, validate it against an allow-list or catch the exception from a missing property; building a tree from unchecked user input is a data-shape risk even though it is not a SQL-injection risk, since parameterized values still flow through the provider normally.
public static IOrderedQueryable<T> OrderByProperty<T>(
this IQueryable<T> source, string propertyName, bool descending = false)
{
var parameter = Expression.Parameter(typeof(T), "x");
var property = Expression.Property(parameter, propertyName); // throws if missing
var keySelector = Expression.Lambda(property, parameter);
var call = Expression.Call(
typeof(Queryable),
descending ? nameof(Queryable.OrderByDescending) : nameof(Queryable.OrderBy),
[typeof(T), property.Type],
source.Expression,
Expression.Quote(keySelector));
return (IOrderedQueryable<T>)source.Provider.CreateQuery<T>(call);
}What interviewers look for: familiarity with the Expression factory API beyond "I've seen it once," and the instinct to validate external input before it becomes part of a tree, plus awareness that the result still flows through the provider (source.Provider.CreateQuery) rather than being executed directly.
- Common mistakes: forgetting
Expression.Quotearound a nested lambda argument, which is required whenever you pass a lambda into an already-builtMethodCallExpressiontargeting aQueryablemethod. - Follow-up questions: How would you combine two independently built predicates with logical AND while keeping the whole thing translatable? (Rewrite one predicate's parameter to match the other's using an
ExpressionVisitor, then build anAndAlsonode, rather than callingCompile()on either half.)
Q4 What does compiling an expression tree cost, and how does that change under Native AOT?#
Short answer: Expression.Compile() generates IL at run time through the same dynamic-code machinery as reflection emit, so a single compilation is relatively expensive compared to invoking the resulting delegate; the right pattern is to compile once, cache the delegate in a static field, and invoke the cached delegate on every subsequent call. Under Native AOT, there is no run-time code generation available at all, so compiled expressions always execute through an interpreter instead, which is measurably slower than the JIT-compiled delegate you would get on a normal .NET deployment.
For EF Core specifically, EF.CompileQuery and EF.CompileAsyncQuery compile a query's translation once into a thread-safe delegate that skips the per-call query-cache lookup entirely, which is worth reaching for on the hottest, most repeated queries in a service rather than as a default for every query.
using System.Linq.Expressions;
Expression<Func<int, bool>> isAdult = age => age >= 18;
Func<int, bool> compiled = isAdult.Compile(); // expensive: do this once
Console.WriteLine(compiled(21)); // cheap: invoke the cached delegate
private static readonly Func<ShopContext, Guid, IAsyncEnumerable<Order>> ByCustomer =
EF.CompileAsyncQuery((ShopContext db, Guid customerId) =>
db.Orders.Where(o => o.CustomerId == customerId).OrderByDescending(o => o.PlacedAt));What interviewers look for: the "compile once, invoke many" discipline, and specific awareness that Native AOT forces interpretation, which is a real deployment consideration for any library that compiles expressions internally.
- Common mistakes: calling
Compile()inside a hot method instead of caching the result, which pays the compilation cost on every call. - Follow-up questions: When would
Compile(preferInterpretation: true)be the right choice even outside Native AOT? (When the expression runs only once or a few times, so the interpreter's lower startup cost beats paying to JIT-compile code you will barely use.)
Q5 What is the multiple-enumeration bug, and how do you catch or prevent it?#
Short answer: A deferred IEnumerable<T> re-runs its entire pipeline every time something enumerates it, so calling Any(), Count() and then foreach on the same unmaterialized sequence can run the underlying work two or three times, and if the source has side effects or reads from a changing store, the calls can even observe different results. The fix is to materialize once, with ToList(), ToArray() or a collection expression, at the point where you need a stable, reusable snapshot.
.NET 7 added analyzer rule CA1851 to flag likely multiple enumerations, but it is not enabled by default, so codebases that pass IEnumerable<T> around should opt into it explicitly in .editorconfig. A useful API design rule follows from the same insight: accept IEnumerable<T> in a method that only enumerates once, and ask for IReadOnlyCollection<T> or IReadOnlyList<T> when your method needs a count or more than one pass, since the stronger type documents and enforces the "already materialized" expectation at the call site.
// Risky: each call below re-runs the underlying query or iterator.
IEnumerable<Order> overdue = repository.StreamOverdueOrders();
if (overdue.Any())
{
logger.LogWarning("{Count} overdue orders", overdue.Count()); // second enumeration
await notifier.SendAsync(overdue, ct); // third enumeration
}
// Safer: materialize once, then reuse the stable snapshot.
IReadOnlyList<Order> overdueList = [.. repository.StreamOverdueOrders()];
if (overdueList.Count > 0)
{
logger.LogWarning("{Count} overdue orders", overdueList.Count);
await notifier.SendAsync(overdueList, ct);
}What interviewers look for: recognition that this is both a performance bug and a correctness bug when the source has side effects or reads live data, plus a concrete design rule for choosing parameter types.
- Common mistakes: "fixing" this by adding
.ToList()reflexively everywhere, which defeats deferred streaming even in places that genuinely enumerate once. - Follow-up questions: Why is
TryGetNonEnumeratedCount(.NET 6) useful here? (It returns a count without forcing enumeration when the underlying source already knows its length, avoiding an unnecessary materialization just to check size.)
Q6 What triggers client-side evaluation in EF Core, and why is it dangerous when it happens silently?#
Short answer: Client-side evaluation happens whenever part of a LINQ query cannot be translated into the target database's query language, and EF Core has to run that part in memory instead. Since EF Core 3.0, this is only permitted in the final projection of a query; anywhere else in the pipeline, an untranslatable expression throws at run time instead of silently downloading the whole table and filtering it in your process, which was the dangerous pre-3.0 behavior this change specifically fixed.
The practical risk today is narrower but still real: a method call inside the final Select that cannot translate runs once per row in memory after the rest of the query already filtered and paged at the database, which is usually fine, but a method inside a Where clause that happens to translate for one database provider and not another turns into an environment-specific run-time exception instead of a compile-time error, since the compiler cannot check translatability.
// Throws at run time: IsVip can't translate, and it isn't in the final projection.
var fails = await db.Orders.Where(o => IsVip(o.CustomerId)).ToListAsync(ct);
// Fine: FormatLabel runs client-side, but only after filtering and paging at the database.
var page = await db.Orders
.Where(o => o.PlacedOn >= since)
.OrderByDescending(o => o.PlacedOn)
.Take(20)
.Select(o => new { o.Id, Label = FormatLabel(o) }) // client-side in the final projection
.ToListAsync(ct);What interviewers look for: the specific EF Core 3.0 behavior change (throw instead of silently falling back), and the "only in the final projection" rule stated precisely, not a vague "EF sometimes runs things in memory."
- Common mistakes: assuming any untranslatable method anywhere in a query silently falls back to in-memory execution on modern EF Core; that has not been true since EF Core 3.0.
- Follow-up questions: How would you catch a translation failure before it reaches production? (Exercise the query against a real or containerized instance of the target database in integration tests, since translatability is provider-specific and cannot be verified from the LINQ alone.)
Q7 What C# constructs can't appear inside an expression tree, and why does o.Customer is not null sometimes fail where o.Customer != null works?#
Short answer: The compiler can only turn expression-bodied lambdas into trees, and even then several modern constructs are excluded outright: ?., await, throw expressions, tuple literals, collection expressions, local functions, and pattern matching with is or switch expressions. o.Customer is not null uses an is pattern, which the tree-building compiler pass does not support, while o.Customer != null is an ordinary binary comparison, which it does, so the two logically equivalent expressions are not equally usable inside Expression<Func<T, bool>>.
This is a real, recurring EF Core trap: developers adopt pattern-based null checks as a style rule for ordinary code, and the same habit inside a Where on IQueryable<T> either fails to compile or, in a dynamically built tree, fails at run time depending on how the tree was produced. The practical rule is to keep query predicates on the plainer subset of C# that expression trees actually support, and reserve pattern matching for code that runs as ordinary delegates.
What interviewers look for: the specific list of unsupported constructs, or at least fluency with the most common ones, and the is not null versus != null example as a concrete illustration rather than an abstract rule.
- Common mistakes: assuming any C# 8-or-later syntax that compiles in general is automatically expression-tree compatible; several were deliberately never supported.
- Follow-up questions: Why do C# 14's first-class span conversions complicate this further for some LINQ providers? (A call like
array.Contains(value)can now bind to a vectorized span method that an interpreted or older provider cannot handle, so some queries need an explicit cast toIEnumerable<T>to keep translating.)
Q8 How do captured variables and literal constants differently affect EF Core's query plan cache?#
Short answer: EF Core caches a compiled query translation keyed by the shape of the expression tree, and a captured local variable becomes a SQL parameter, so many calls that differ only in that value reuse one cached plan. A literal constant embedded directly in the lambda becomes part of the tree's shape itself, so every distinct literal value produces a different tree and a separately compiled, separately cached query, which silently grows the cache and wastes compilation time as the number of distinct literals grows.
This is why "just filter by this hardcoded status for now" is a worse habit in EF Core code than it looks: a Where(o => o.Status == OrderStatus.Pending) written as a literal compiles and caches once, but if a code path builds the same query with several different literal values across call sites, each is a separate cache entry, whereas passing the same values in through a captured variable collapses them into one cached, parameterized query.
// Preferred: a captured variable becomes one SQL parameter; one cached plan serves every value.
var status = OrderStatus.Pending;
var pending = await db.Orders.Where(o => o.Status == status).ToListAsync(ct);
// Avoid in a hot path: each distinct literal compiles and caches a separate query shape.
var pendingLiteral = await db.Orders.Where(o => o.Status == OrderStatus.Pending).ToListAsync(ct);What interviewers look for: the shape-based caching mechanism explained correctly, and a concrete reason it matters operationally (cache growth, compilation cost), not just "parameters are more secure," which is true but is not the point being tested here.
- Common mistakes: assuming EF Core parameterizes every value automatically regardless of whether it appears as a literal or a captured variable; the tree shape is what decides.
- Follow-up questions: Would a dynamically built expression tree from user input (as in the "sort by property name" example) have the same caching behavior? (Yes in principle, but a tree rebuilt from scratch per call, with property names embedded structurally, tends to produce more distinct shapes and less cache reuse than parameterized values do.)
Q9 When does LINQ cost more than it is worth in a hot path, and what do you replace it with?#
Short answer: Every operator in a LINQ to Objects chain allocates an iterator object, every lambda that captures a variable allocates a closure and a delegate per call, and enumerating a List<T> through IEnumerable<T> boxes its otherwise allocation-free struct enumerator. In code that runs a handful of times per request, none of this registers; in a loop that runs millions of times, it becomes visible GC pressure and lost throughput, and that is the specific threshold worth measuring before rewriting anything.
Modern LINQ is smarter than its reputation in many common shapes: Sum, Min and Max detect exact array and List<T> sources and use vectorized span code, Count() reads ICollection<T>.Count without enumerating, and combined Where-then-Select chains over arrays and lists use specialized iterators. So the honest hot-path answer is: measure with a benchmark harness first, keep LINQ everywhere it is not the bottleneck, and reach for foreach over a span, or CollectionsMarshal.AsSpan for a List<T>, specifically in the loop the profiler flags.
// Idiomatic, but allocates a closure, a delegate and an enumerator per call.
static int CountAboveLinq(List<int> values, int limit) => values.Count(v => v > limit);
// Hot-path version: no allocations, direct access to the list's backing array.
static int CountAboveLoop(List<int> values, int limit)
{
var count = 0;
foreach (var v in CollectionsMarshal.AsSpan(values))
if (v > limit) count++;
return count;
}What interviewers look for: a measured, threshold-based answer rather than a blanket "LINQ is slow, always use loops," and specific knowledge of where the built-in operators already optimize common shapes.
- Common mistakes: rewriting readable LINQ into loops throughout a codebase based on general reputation rather than a profiler pointing at the specific call site.
- Follow-up questions: How would marking lambdas
statichelp even outside the very hottest paths? (It turns any accidental capture into a compile error, which is a correctness and readability win independent of the allocation savings.)
Q10 You're reviewing a pull request with a complex query that mixes IQueryable composition and in-memory LINQ. What do you check?#
Short answer: First, find the exact point where the query stops being IQueryable<T> and becomes IEnumerable<T>, usually an AsEnumerable(), a ToList() placed too early, or a method call that cannot translate, and confirm that filtering, sorting and paging all happen before that point rather than after it. Second, check for multiple enumeration of anything deferred that is reused more than once in the method. Third, check whether any predicate embeds a literal that should be a parameter, and whether the query composes cleanly enough to be unit-testable against an in-memory or containerized provider.
A query that looks correct can still be a production risk if it silently loads more rows than the reviewer expects, if it recomputes a deferred sequence three times, or if a translatable-today method call becomes untranslatable after a provider upgrade with no compile-time signal. The review is really about where boundaries are, not about LINQ syntax style, which is why tracing the IQueryable-to-IEnumerable transition first tends to surface the most consequential issues fastest.
What interviewers look for: a systematic review process anchored on execution boundaries (translation boundary, enumeration count, parameterization) rather than a list of unrelated LINQ style nitpicks.
- Common mistakes: approving a query because it "reads fine" without tracing where it actually executes, which is the single most common source of LINQ-related production incidents.
- Follow-up questions: How would you add a regression test that catches a future change accidentally moving filtering client-side? (Assert on the generated SQL, or on row counts returned versus rows the database should have filtered, in an integration test against a real provider.)
Quick-Fire Round#
| Question | Answer |
|---|---|
Is OrderBy streaming or non-streaming? | Non-streaming; it must read the whole source before yielding |
What type do Queryable operators take instead of Func<T, TResult>? | Expression<Func<T, TResult>> |
Since which EF Core version does an untranslatable Where clause throw instead of falling back? | EF Core 3.0 |
| What does a captured variable become in an EF Core query? | A SQL parameter, sharing one cached query plan |
| What does a literal constant become in an EF Core query? | Part of the tree shape, producing a separately cached query per value |
| What analyzer rule flags possible multiple enumeration? | CA1851, not enabled by default |
Can is not null be used inside every expression tree? | No; pattern-based is checks are not supported, unlike != null |
| What does Native AOT do to a compiled expression? | It always runs interpreted; there is no run-time JIT to fall back to |
| What EF Core API compiles a query once into a reusable delegate? | EF.CompileQuery / EF.CompileAsyncQuery |
How to Prepare#
- Be able to state, for any LINQ operator you name, whether it is immediate, deferred-streaming or deferred-non-streaming, without hesitating.
- Practice writing a small dynamic "sort by property name" helper with
Expression.Parameter,Expression.PropertyandExpression.Lambdafrom memory. - Know the EF Core 3.0 client-evaluation rule precisely: allowed only in the final projection, exception everywhere else.
- Be ready to explain query plan cache pollution from literals versus captured variables with a concrete before-and-after example.
- Prepare a war story about a multiple-enumeration or silent client-evaluation bug you found in review or production; this topic rewards specifics.