IQueryable<T>: the provider model, Queryable versus Enumerable overload binding, crossing between database and memory with AsEnumerable, and the design debate about exposing IQueryable.
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.
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.
IQueryable<T> interfacepublic 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.Queryable methods build expression treespublic 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)));
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.Compare Enumerable.Where(this IEnumerable<T>, Func<T,bool>) — a compiled delegate executed per item in memory.
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.
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).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):
DbContext is disposed.AsQueryable() tests pass for queries that fail against SQL Server.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.
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.
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.
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).
GetEnumerator() on an IQueryable<T> calls Provider.Execute<IEnumerable<T>>(Expression) (EF Core: translate, run SQL, materialise).Count, First, Any) call Provider.Execute<TResult>(expression with the operator).IAsyncQueryProvider.ExecuteAsync, reached via ToListAsync, FirstOrDefaultAsync, etc. (extension methods in Microsoft.EntityFrameworkCore).IEnumerableIEnumerable<T> parameters.IEnumerable (some newer operators may not have Queryable equivalents yet).Func<T,bool> variable to Where (binds to Enumerable.Where).foreach over the query, then filtering inside the loop.IEnumerable<T>.EF Core logs a warning only in some cases; review SQL with ToQueryString() and logging.
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.
EnumerableQuery<T> in testslist.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.
DbSet to IEnumerable<T>, apply Where, and compare the SQL with the IQueryable<T> version.query.Expression.ToString() for a composed query.IEnumerable<T> and over IQueryable<T> and compare row counts fetched.AsQueryable() in a test and against EF Core.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})";
}
IEnumerableWhat 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.
Func<T,bool> variables to EF queriesWhat 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>>.
IQueryable from repositories across layersWhat 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.
AsQueryable() testsWhat 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.
AsEnumerable() too earlyWhat happens: db.Orders.AsEnumerable().Where(...).
Why it is wrong: loads the whole table.
Do instead: filter and project in SQL before switching.
What happens: returning IQueryable from methods that dispose the context.
Why it is wrong: ObjectDisposedException on enumeration.
Do instead: materialise inside the scope.
What happens: calling expression.Compile() per request.
Why it is wrong: expensive compilation each time.
Do instead: cache compiled delegates if you need them.
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.
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.
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.
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.
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.
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.
Last updated 30 Oct 2026.