Querying Out-of-Process Data · How it works

8 min read
Senior11 min read
Rapid overview

How it works

In-process vs out-of-process

Q: What does "querying out-of-process data" mean?

A: Querying data that is not in your process's memory, held by another program, usually on another machine: a relational database, a document store, a remote API. Every query crosses a process and usually a network boundary, which has two consequences: (1) your C# code cannot run where the data is, so the query must be translated into the other system's language; (2) each round trip costs milliseconds, not nanoseconds, so how many queries you send and how much data they return matters far more than CPU work in C#.

OperatorsSystem.Linq.EnumerableSystem.Linq.Queryable
Lambda parameterFunc<T, bool> (compiled delegate)Expression<Func<T, bool>> (expression tree)
What an operator doeswraps the source in an iterator that calls your delegateadds a method-call node to the query's expression tree
Where the filter runsin your process, row by rowtranslated (e.g. to SQL), runs in the database
Unsupported codeanything C# can do worksuntranslatable calls throw at runtime
Q: How does LINQ to Objects differ from LINQ to a database?

A:

The same query syntax compiles to either; the static type of the source decides which operators the compiler binds to.


How IQueryable works

Q: What is IQueryable<T> made of?

A: Three things: an Expression (the tree describing the query so far), an ElementType, and a Provider (IQueryProvider). Each Queryable operator calls source.Provider.CreateQuery<T>(newExpression), where the new expression is a MethodCallExpression wrapping the previous one: for example Queryable.Where(<previous query>, o => o.Total > 100). No database call happens. When you enumerate (or call ToList, First, Count…), the provider's Execute walks the whole tree, translates it, runs it, and turns the results into objects.

IQueryable<Order> q = db.Orders;                 // Expression: the DbSet itself
q = q.Where(o => o.Total > 100);                 // Expression: Where(DbSet, o => o.Total > 100)
q = q.OrderByDescending(o => o.CreatedAt);       // Expression: OrderByDescending(Where(...), ...)
var page = q.Select(o => new { o.Id, o.Total })  // Expression: Select(OrderByDescending(...), ...)
            .Take(20)
            .ToList();                            // ONE SQL statement executed here

// SELECT TOP(20) o.Id, o.Total FROM Orders o
// WHERE o.Total > 100.0 ORDER BY o.CreatedAt DESC
  1. Build: the operators assemble an expression tree (cheap, in memory).
  2. Cache lookup: EF Core compares the tree's shape with its query cache; captured variables are treated as parameters, so the same shape with different values hits the cache.
  3. Translate (on a cache miss): the tree is visited, mapped to the model, and turned into SQL.
  4. Execute: SQL and parameters go to the database over the connection; one round trip.
  5. Materialize: a DbDataReader streams rows back; EF Core creates entity objects (and, if tracking, registers them in the change tracker) or projection objects.
Q: What happens step by step when an EF Core query executes?

A:

Q: How do captured variables become SQL parameters?

A: In an expression tree a captured local appears as a member access on the closure object (value(DisplayClass).minTotal), not as a constant. EF Core recognizes that pattern and turns it into a DbParameter (@__minTotal_0). That is why changing minTotal does not produce a new SQL string or a new query plan, and why this is safe from SQL injection. A literal written in the lambda (o.Total > 100) is inlined as a constant.


Where the boundary falls

  • AsEnumerable(): changes the static type to IEnumerable<T>; every operator after it runs in C# on rows streamed from the database.
  • ToList() / ToArray() / ToDictionary(): execute the query now and buffer all results in memory; anything after works on the list.
  • Passing a Func<> (not an Expression<Func<>>) to Where or Select: overload resolution picks Enumerable, so EF Core loads everything up to that point.
  • Assigning to or returning IEnumerable<T>: a repository method IEnumerable<Order> GetOrders() that returns db.Orders makes every caller's .Where(...) run in memory.
Q: What switches a query from the database to memory?

A:

// All in SQL
var a = db.Orders.Where(o => o.Total > 100).Select(o => o.Id).ToList();

// Filter in SQL, formatting in C# (deliberate and fine)
var b = db.Orders.Where(o => o.Total > 100)
                 .Select(o => new { o.Id, o.CreatedAt })
                 .AsEnumerable()
                 .Select(o => $"{o.Id} on {o.CreatedAt:d}")
                 .ToList();

// Whole table loaded, then filtered in memory
IEnumerable<Order> all = db.Orders;
var c = all.Where(o => o.Total > 100).ToList();
Q: What happens if a query contains code the provider cannot translate?

A: Since EF Core 3.0 it throws InvalidOperationException: The LINQ expression … could not be translated instead of silently pulling the data into memory (EF Core 1-2 did "client evaluation", which caused hidden full-table loads). Typical causes: calling your own C# method inside Where (o => IsVip(o)), string or date functions the provider does not map, or complex logic on navigation collections. Fix it by expressing the logic with translatable operations, mapping a database function ([DbFunction]), or moving that part after AsEnumerable() once the result set is small. EF Core still allows client evaluation in the final Select projection, because that does not change which rows are fetched.


Performance traps

Q: What is the N+1 query problem?

A: Loading a list with one query and then running one more query per row to get related data: 1 + N round trips. It happens with lazy loading (touching order.Customer in a loop) or with a query inside a foreach. With 500 orders and 2 ms per round trip that is a full second.

// N+1
foreach (var o in db.Orders.ToList())
    Console.WriteLine(db.Customers.Single(c => c.Id == o.CustomerId).Name);

// One query with a JOIN
var rows = db.Orders
    .Select(o => new { o.Id, CustomerName = o.Customer.Name })
    .ToList();

Fixes: project the related fields in Select, use Include for entity graphs, or load the related set in one query (WHERE Id IN (...)) and join in memory.

Q: Why prefer Select projections over loading entities?

A: A projection fetches only the columns you name, avoids change-tracking overhead, and lets the database use covering indexes. Loading full entities to show three fields transfers every column, allocates full objects and, if tracked, snapshots them. For read-only queries use projections, or at least AsNoTracking().

Q: What is wrong with enumerating an IQueryable twice?

A: Each enumeration re-executes the query: two round trips, and possibly different results if the data changed in between. var q = db.Orders.Where(…); if (q.Any()) foreach (var o in q) … runs two SQL statements. Materialize once with ToList() when you need the results more than once.

Q: Count() > 0 or Any()?

A: Any(). It translates to EXISTS (…), which can stop at the first matching row; Count() makes the database count every match.

Q: What is a cartesian explosion, and how do split queries help?

A: Include-ing two collections (Include(o => o.Lines).Include(o => o.Payments)) produces one SQL JOIN whose row count is lines × payments per order, duplicating data massively. AsSplitQuery() makes EF Core send one query per collection instead, trading one round trip for several smaller result sets.

Q: How do Contains on a local list and large IN clauses behave?

A: db.Orders.Where(o => ids.Contains(o.Id)) becomes WHERE Id IN (…). Older EF Core versions inlined every value, producing a new SQL string (and plan) for every list size. EF Core 8 sends the list as a single JSON parameter (OPENJSON on SQL Server), so the SQL stays stable. Very large lists are still better handled with a temporary table or a join.


Memory level

Q: Does an EF Core query stream results or load them all into memory?

A: It depends on how you consume it. foreach over the IQueryable (or AsAsyncEnumerable()) streams: rows are read from the DbDataReader one at a time and each object can be collected once you are done with it (unless the change tracker keeps it). ToList() buffers: every row is materialized and held in a list at once. For exports of millions of rows use streaming plus AsNoTracking(), or you will hold the whole result set, and a tracking snapshot of each entity, in memory.

Q: What is allocated when you build and run a LINQ-to-EF query?

A: The expression tree nodes (one heap object per node, rebuilt every time the query code runs), the closure object for captured variables, EF Core's parameter values, the SQL command, and the materialized result objects. After the first run, translation is skipped thanks to the query cache. For very hot queries EF.CompileQuery / EF.CompileAsyncQuery pre-compile the query into a delegate so even the tree-building and cache lookup are skipped.


Seeing what runs

Q: How do you see the SQL a LINQ query produces?

A: Call query.ToQueryString() (EF Core 5+), enable logging (optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information) or the Microsoft.EntityFrameworkCore.Database.Command log category), or use a database profiler. Reading the SQL is the only reliable way to know whether a filter ran in the database or in memory.

Q: Which other LINQ providers query out-of-process data?

A: EF Core (SQL Server, PostgreSQL, SQLite, Cosmos DB), the MongoDB C# driver (AsQueryable() → aggregation pipeline), OData clients (→ URL query options), Azure Table/Cosmos SDKs, and legacy LINQ to SQL. All follow the same pattern: expression tree in, native query out, and each supports a different subset of C#.


Common interview gotchas

Q: Should a repository return IQueryable<T> or IEnumerable<T>?

A: It is a trade-off. IQueryable<T> lets callers compose filters and paging that still run in SQL, but it leaks persistence concerns and allows untranslatable or expensive queries to be built anywhere. IEnumerable<T> (or IReadOnlyList<T>) returned from a materialized query is predictable, but any extra filtering by callers happens in memory. Common practice: repositories or query handlers take the criteria as parameters (or specification objects built from expressions) and return materialized lists or DTOs.

Q: Why is db.Orders.ToList().Where(o => o.Total > 100) slow?

A: ToList() executes SELECT * FROM Orders and loads every row; the Where then runs in memory. Put Where before ToList().

Q: Why can't you call DateTime.Now.AddDays(-7) inside a query?

A: You can: EF Core evaluates expressions that do not depend on the row (such as DateTime.Now.AddDays(-7)) and sends the result as a parameter, or translates it to the database's own function (for example GETDATE()). What you cannot do is call arbitrary C# on row values, such as o => MyHelper.Score(o) > 5.