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

📚 Interview question

What is IQueryable<T>, and how is it different from IEnumerable<T>? Explain Expression, ElementType and Provider, how Queryable methods build expression trees, why the static type of a variable decides where a query runs, AsEnumerable and AsQueryable, and whether a repository should expose IQueryable.

IQueryable<T>: the provider model, Queryable versus Enumerable overload binding, crossing between database and memory with AsEnumerable, and the design debate about exposing IQueryable.

Senior Theory Advanced architecture csharp ef-core iqueryable linq

🎯 Key points

  • `IQueryable<T>` extends `IEnumerable<T>` with `Expression`, `ElementType` and `Provider`
  • `Queryable` methods take `Expression<Func<...>>` and return a new IQueryable whose expression wraps the previous one; the provider executes it on enumeration
  • `IEnumerable<T>` methods take `Func<...>` delegates and run in memory
  • Extension method binding uses the static type: an IQueryable stored in an IEnumerable variable switches to LINQ to Objects for later operators
  • `AsEnumerable()` deliberately moves the rest of the query into memory; `AsQueryable()` on an in-memory collection gives an EnumerableQuery that compiles expressions
  • Providers support only a subset of expressions; translation errors surface at execution time
  • Exposing IQueryable from repositories gives flexibility but leaks persistence concerns and makes queries untestable without a provider

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

IQueryable<T> represents a query that a provider (EF Core, OData, Cosmos SDK) will translate and execute elsewhere — usually as SQL. IEnumerable<T> represents a sequence processed in memory by .NET code. The crucial difference is that IQueryable operators take expression trees (data describing code) while IEnumerable operators take delegates (compiled code). I'd cover the three members of IQueryable, how Queryable methods build expression trees, why the variable's static type decides where code runs, AsEnumerable/AsQueryable, and whether repositories should expose IQueryable.

1. The IQueryable<T> interface

public interface IQueryable : IEnumerable
{
    Type ElementType { get; }        // T
    Expression Expression { get; }   // the expression tree describing the query so far
    IQueryProvider Provider { get; } // knows how to build and execute queries
}
public interface IQueryable<out T> : IEnumerable<T>, IQueryable { }
  • Expression: the query as a tree, e.g. Call(Queryable.Where, [Constant(DbSet<Order>), Quote(Lambda(o => o.Total > 100))]).
  • Provider: CreateQuery<T>(Expression) builds a new queryable with a bigger tree; Execute<T>(Expression) runs it (for scalars like Count); enumeration also goes through the provider.
  • ElementType: the element type.

2. How Queryable methods build expression trees

public static IQueryable<T> Where<T>(this IQueryable<T> source, Expression<Func<T, bool>> predicate)
    => source.Provider.CreateQuery<T>(
        Expression.Call(null, WhereMethodInfo.MakeGenericMethod(typeof(T)), source.Expression, Expression.Quote(predicate)));
  • The lambda o => o.Total > 100 is compiled by the C# compiler into an expression tree (because the parameter type is Expression<Func<...>>), not IL.
  • Where doesn't filter anything — it wraps the existing expression in a Call node and asks the provider for a new IQueryable.
  • On enumeration, EF Core translates the full tree to SQL.

Compare Enumerable.Where(this IEnumerable<T>, Func<T,bool>) — a compiled delegate executed per item in memory.

3. The static type decides where the query runs

Overload resolution picks Queryable.Where or Enumerable.Where from the compile-time type of the variable:

IQueryable<Order> q = db.Orders;
var a = q.Where(o => o.Total > 100);          // Queryable.Where -> SQL WHERE

IEnumerable<Order> e = db.Orders;              // same object, different static type!
var b = e.Where(o => o.Total > 100);          // Enumerable.Where -> SELECT * then filter in memory

Assigning a DbSet to an IEnumerable<T> variable, passing it to a method that accepts IEnumerable<T>, or calling a helper that takes Func<T,bool> silently moves the rest of the query into memory — one of the most expensive EF Core mistakes.

4. AsEnumerable() and AsQueryable()

  • AsEnumerable(): changes the static type to IEnumerable<T> deliberately, so later operators run in memory. Use it after the database-side work, for logic SQL can't express:
var result = db.Orders.Where(o => o.Status == Paid)
    .Select(o => new { o.Id, o.Total, o.CreatedAt })       // SQL
    .AsEnumerable()
    .Select(x => new OrderVm(x.Id, Format(x.Total), Humanise(x.CreatedAt)));   // memory

(In EF Core, client evaluation in the final Select is allowed anyway; AsEnumerable makes the boundary explicit.)

  • AsQueryable(): wraps an IEnumerable<T> in an EnumerableQuery<T> so code expecting IQueryable<T> works over in-memory data (e.g. in tests). It doesn't make in-memory data go to a database, and behaviour can differ from a real provider (e.g. null handling, case sensitivity, unsupported methods succeed in memory but fail in EF).

5. Should a repository expose IQueryable?

Arguments for: flexible composition (paging, filtering, projection) without writing many methods; callers can project to exactly the DTO they need.

Arguments against (my default):

  • It's a leaky abstraction: callers must know EF Core translation rules; untranslatable expressions fail at run time.
  • Unpredictable queries: anyone can add includes, N+1 patterns or expensive operations; performance can't be reviewed in one place.
  • Lifetime issues: the query executes later, possibly after the DbContext is disposed.
  • Testing: in-memory AsQueryable() tests pass for queries that fail against SQL Server.
  • It defeats the purpose of a repository (hiding persistence).

My approach: repositories return materialised results from intention-revealing methods (GetUnshippedOrdersAsync(before, ct)); read-side queries use dedicated query handlers that use DbContext/IQueryable internally and return DTOs. Some teams use the Specification pattern to encapsulate reusable query logic as expression trees. Exposing IQueryable can be acceptable inside a single layer (e.g. a query service) or for OData endpoints.

6. A production example

A generic helper IEnumerable<T> Page<T>(IEnumerable<T> source, int page, int size) was used on EF queries. Because the parameter was IEnumerable<T>, Skip/Take ran in memory after loading all rows — a list page loaded 2 million rows. Changing the helper to IQueryable<T> Page<T>(IQueryable<T> source, ...) restored SQL OFFSET/FETCH, and the endpoint dropped from 30 s to 80 ms.

🔍 What the interviewer is checking

Separates people who use EF Core from people who understand LINQ providers.

Green flags: Expression/ElementType/Provider; Queryable versus Enumerable binding by static type; AsEnumerable as a deliberate boundary; AsQueryable test pitfalls; balanced view on exposing IQueryable.

Red flags: "IQueryable is just IEnumerable for databases"; returns IEnumerable from EF repositories and filters later; no awareness of translation limits.

📚 Learn in depth

1. Expression trees vs delegates

Func<Order, bool> f = o => o.Total > 100;                  // compiled IL
Expression<Func<Order, bool>> e = o => o.Total > 100;      // data structure

The compiler emits code that constructs e at run time: Expression.Lambda(Expression.GreaterThan(Expression.Property(p, "Total"), Expression.Constant(100m)), p). Providers walk the tree (via ExpressionVisitor) to translate it. You can compile it to a delegate with e.Compile(), which is expensive (or interpreted under AOT).

2. Provider execution

  • GetEnumerator() on an IQueryable<T> calls Provider.Execute<IEnumerable<T>>(Expression) (EF Core: translate, run SQL, materialise).
  • Scalar operators (Count, First, Any) call Provider.Execute<TResult>(expression with the operator).
  • Async: EF Core's IAsyncQueryProvider.ExecuteAsync, reached via ToListAsync, FirstOrDefaultAsync, etc. (extension methods in Microsoft.EntityFrameworkCore).

3. Hidden switches to IEnumerable

  • Calling methods with IEnumerable<T> parameters.
  • Using LINQ methods that only exist for IEnumerable (some newer operators may not have Queryable equivalents yet).
  • Passing a Func<T,bool> variable to Where (binds to Enumerable.Where).
  • foreach over the query, then filtering inside the loop.
  • Extension methods you write over IEnumerable<T>.

EF Core logs a warning only in some cases; review SQL with ToQueryString() and logging.

4. Specification pattern

public sealed class UnshippedBefore(DateTimeOffset before) : Specification<Order>
{
    public override Expression<Func<Order, bool>> ToExpression() => o => o.Status == OrderStatus.Paid && o.PaidAt < before;
}
var orders = await db.Orders.Where(new UnshippedBefore(cutoff).ToExpression()).ToListAsync(ct);

Specifications keep query logic reusable and testable while remaining translatable, without exposing IQueryable across layers.

5. EnumerableQuery<T> in tests

list.AsQueryable() executes expression trees by compiling them to delegates and running them against the list (LINQ to Objects semantics). Differences from SQL Server: case sensitivity (ordinal vs collation), null comparisons, string functions, DateTime precision, unsupported methods throwing in EF but working in memory. Integration tests against real SQL Server (Testcontainers) catch these.

Try it yourself

  1. Assign a DbSet to IEnumerable<T>, apply Where, and compare the SQL with the IQueryable<T> version.
  2. Inspect query.Expression.ToString() for a composed query.
  3. Write a pagination helper over IEnumerable<T> and over IQueryable<T> and compare row counts fetched.
  4. Run a query with a custom C# method via AsQueryable() in a test and against EF Core.

💻 Code

using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;

namespace Linq.Queryables;

public sealed class Invoice
{
    public int Id { get; set; }
    public Guid TenantId { get; set; }
    public DateOnly DueDate { get; set; }
    public decimal Amount { get; set; }
    public bool IsPaid { get; set; }
}

public sealed class BillingContext(DbContextOptions<BillingContext> options) : DbContext(options)
{
    public DbSet<Invoice> Invoices => Set<Invoice>();
}

public static class InvoiceQueries
{
    // Composable, but kept inside the data layer
    public static IQueryable<Invoice> ForTenant(this IQueryable<Invoice> source, Guid tenantId)
        => source.Where(i => i.TenantId == tenantId);

    public static IQueryable<Invoice> Overdue(this IQueryable<Invoice> source, DateOnly today)
        => source.Where(i => !i.IsPaid && i.DueDate < today);
}

public sealed class InvoiceReader(BillingContext db)
{
    // BUG: IEnumerable parameter type makes every later operator run in memory
    public static int CountBig(IEnumerable<Invoice> invoices) => invoices.Count(i => i.Amount > 10_000m);

    // FIX: intention-revealing method, translated to one SQL query, materialised here
    public Task<List<Invoice>> GetOverdueAsync(Guid tenantId, DateOnly today, CancellationToken ct)
        => db.Invoices.AsNoTracking()
             .ForTenant(tenantId)
             .Overdue(today)
             .OrderBy(i => i.DueDate)
             .ToListAsync(ct);

    // Filter expression parameter keeps translation possible
    public Task<List<Invoice>> FindAsync(Expression<Func<Invoice, bool>> filter, CancellationToken ct)
        => db.Invoices.AsNoTracking().Where(filter).ToListAsync(ct);

    // Deliberate boundary: SQL narrows, then C#-only logic runs in memory
    public async Task<IReadOnlyList<string>> LabelsAsync(Guid tenantId, CancellationToken ct)
    {
        var rows = await db.Invoices.ForTenant(tenantId)
            .Select(i => new { i.Id, i.Amount })
            .ToListAsync(ct);
        return rows.Select(r => FormatLabel(r.Id, r.Amount)).ToList();
    }

    private static string FormatLabel(int id, decimal amount) => $"INV-{id:D6} ({amount:C})";
}

⚠ Common mistakes

1. Accidentally switching to IEnumerable

What happens: helpers or variables typed IEnumerable<T> receive EF queries. Why it is wrong: later filters, sorting and paging run in memory after loading everything. Do instead: keep IQueryable<T> until the query is complete, then materialise.

2. Passing Func<T,bool> variables to EF queries

What happens: db.Orders.Where(predicate) where predicate is a Func. Why it is wrong: binds to Enumerable.Where — full table load. Do instead: use Expression<Func<T,bool>>.

3. Exposing IQueryable from repositories across layers

What happens: controllers compose arbitrary EF queries. Why it is wrong: leaky abstraction, run-time translation failures and unreviewable SQL. Do instead: intention-revealing methods returning DTOs, or query handlers.

4. Trusting AsQueryable() tests

What happens: unit tests use in-memory lists as queryables. Why it is wrong: semantics differ from SQL Server; untranslatable queries pass tests. Do instead: integration-test queries against real SQL Server.

5. AsEnumerable() too early

What happens: db.Orders.AsEnumerable().Where(...). Why it is wrong: loads the whole table. Do instead: filter and project in SQL before switching.

6. Deferred execution after context disposal

What happens: returning IQueryable from methods that dispose the context. Why it is wrong: ObjectDisposedException on enumeration. Do instead: materialise inside the scope.

7. Compiling expression trees repeatedly

What happens: calling expression.Compile() per request. Why it is wrong: expensive compilation each time. Do instead: cache compiled delegates if you need them.

8. Not inspecting generated SQL

What happens: assuming composition produces efficient SQL. Why it is wrong: surprises like in-memory filtering or poor joins go unnoticed. Do instead: ToQueryString(), logging and execution plans.

🏛 Senior insight: trade-offs

IQueryable is powerful because it defers where code runs, and dangerous for the same reason. The static type at each call decides between SQL and memory, so I watch parameter and variable types in data-access code as closely as the queries themselves. Architecturally, I am wary of generic repositories that leak IQueryable: they combine the coupling of EF Core with none of its clarity. I prefer explicit query methods or handlers that use DbContext directly, plus integration tests against the real database.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions Expression<Func<T,bool>>. Why: IQueryable carries an expression tree; tests understanding of code as data.
    How to answer

    An expression tree is code represented as data. If you assign a lambda to Expression<Func<Order,bool>>, the compiler builds an object tree of parameter, member, binary and constant nodes instead of IL, and that is what lets EF Core read your Where and turn it into SQL. You can build trees with the Expression factory methods, rewrite them with ExpressionVisitor, and turn them into delegates with Compile(). Compiling is expensive, so cache the result, and under Native AOT it falls back to a slower interpreter. Only expression-bodied lambdas qualify, and features like ?., tuple literals and switch expressions are not allowed.

  2. When: Candidate talks about translation errors. Why: How EF Core's provider translates the tree in practice.
    How to answer

    In EF Core, DbSet<T> is an IQueryable<T>, so Where, Select and so on build an expression tree, and EF Core translates it to SQL when you enumerate, caching the translation by query shape. Only what EF understands translates, like properties, operators and many string, date and EF.Functions methods. Since EF Core 3.0, client evaluation is allowed only in the final Select, so anything else untranslatable throws. For reads I use AsNoTracking or projections, AsSplitQuery when collection includes explode, and I check the SQL with ToQueryString(), logging and Query Store.

  3. When: Candidate argues against exposing IQueryable from IRepository<T>. Why: The generic repository debate connects to generic interface design.
    How to answer

    A generic interface is a contract with a type parameter, like IComparer<T> or IEquatable<T>. They exist because the old object-based interfaces forced casts and boxed structs: Dictionary<TKey,TValue> uses EqualityComparer<T>.Default, which calls IEquatable<T>.Equals with no boxing. A type can implement several closed versions of the same interface, usually explicitly, and generic interfaces, unlike classes, can be covariant or contravariant. When I design one, like IHandler<TCommand>, I keep it small and make T carry real meaning.

🛠 Real-world problems: how you handle them

Generic repository loaded every tenant's invoices into memory
Situation
A multi-tenant SaaS billing API used `Repository<T>.GetAll()` returning IEnumerable<T>. Services filtered by tenant afterwards.
Signals
Memory per request of hundreds of MB on the invoices endpoint, slow responses for all tenants, SQL logs showing SELECT without WHERE TenantId.
Investigation
Logged EF Core commands and found unfiltered SELECTs; traced the IEnumerable return type causing Enumerable.Where to bind in the services.
Resolution
Replaced GetAll with intention-revealing async methods that take the tenant and filters as expressions and return IReadOnlyList<T>; added a global query filter for TenantId as defence in depth.
Prevention
Added an architecture test forbidding IEnumerable<TEntity> returns from the data layer and an integration test asserting the SQL contains the tenant predicate.

How to tell it (STAR)

**S:** Our billing API used a lot of memory and was slow across all tenants. **T:** Investigate and fix. **A:** I found the repository returned IEnumerable, so every filter ran in memory after loading all tenants' invoices. I replaced it with explicit query methods and added a global tenant filter. **R:** Memory per request fell from about 400 MB to [amount], and we closed a potential cross-tenant exposure.

↩ Usually asked after

More in LINQ

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me