EF Core already caches the query shape for you. Compiled queries shave off the last, small, per-call cost of finding that cache entry — and only that.
Back in Advanced Part V's LINQ-to-EF-Core lesson, you got a promise: "compiled query shapes — covered properly in a later Advanced module, a8, Enterprise Data." The previous lesson delivered half of that promise's context, showing you that EF Core already caches a query's translated shape automatically, for free, on every ordinary LINQ query. So what's left to actually teach? A fair question — and the honest answer is: not very much, and that's exactly the point. EF.CompileQuery is a narrow, specific, opt-in tool for shaving off the last sliver of overhead that automatic caching doesn't eliminate — nothing more dramatic than that.
In this lesson, you'll learn exactly what EF.CompileQuery and EF.CompileAsyncQuery add on top of automatic shape caching, how to write and call a compiled query, and — more importantly than the syntax — a grounded, honest sense of when it's actually worth reaching for versus when it's premature optimization you should skip.
A compiled query is a LINQ query you deliberately pre-compile into a reusable delegate, once, ahead of time — instead of letting EF Core discover, look up, and dispatch to the cached shape freshly on every call. You call the delegate directly; there's no expression tree to build and no cache lookup to perform at all, because that work already happened when you compiled it.
EF.CompileQuery (synchronous) and EF.CompileAsyncQuery (asynchronous) take a lambda expression describing a query — with explicit parameters for anything that varies between calls — and return a compiled Func<...> delegate you store, typically as a static readonly field, and invoke directly wherever you'd otherwise have written the equivalent inline LINQ query.
private static readonly Func<AppDbContext, int, Task<Product?>> GetProductById =
EF.CompileAsyncQuery((AppDbContext context, int id) =>
context.Products.FirstOrDefault(p => p.Id == id));
// Calling it — no LINQ expression tree built at the call site at all:
Product? product = await GetProductById(context, productId);The previous lesson established what EF Core already does for you automatically: the first time a query's shape runs, it's translated into an internal representation and cached; later calls with the same shape reuse that cached translation instead of re-parsing the expression tree from SQL-generation scratch. That's a genuinely large win, and it's why ordinary LINQ-to-EF-Core queries perform reasonably well without you doing anything special. But it isn't literally free. Every ordinary call still has to: build the LINQ expression tree for that call (allocating the tree's nodes fresh, every time, even though the shape is identical to last time), and look up the matching cached shape by walking and comparing that expression tree against the cache. Both steps are small, and for the overwhelming majority of queries in the overwhelming majority of applications, "small" is the end of the story — genuinely not worth a second thought.
EF.CompileQuery does the expression-tree-building and shape-lookup work exactly once, at compile-time (in the "compile this delegate" sense, not the C# compiler sense), and hands you back a delegate that goes essentially straight to execution on every call thereafter — no tree to build, no cache to consult, because there's nothing left to look up: the specific compiled plan is already sitting behind that delegate. For a query that runs an enormous number of times in a genuinely hot path, eliminating that small-but-nonzero per-call cost, multiplied by an enormous call count, can add up to a real, measurable difference. For a query that runs occasionally, it doesn't — there's nothing meaningful to eliminate a small amount of, from a small number of calls.
public class ProductRepository
{
private static readonly Func<AppDbContext, int, Task<Product?>> GetByIdCompiled =
EF.CompileAsyncQuery((AppDbContext context, int id) =>
context.Products.AsNoTracking().FirstOrDefault(p => p.Id == id));
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context) => _context = context;
// The plain, ordinary equivalent — relies on Layer 1's automatic caching:
public Task<Product?> GetByIdAsync(int id) =>
_context.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id);
// The compiled equivalent — Layer 2, opted in explicitly:
public Task<Product?> GetByIdCompiledAsync(int id) =>
GetByIdCompiled(_context, id);
}Both methods return the exact same result for the exact same id. The only difference is what happens internally before the SQL runs: the plain version builds a fresh expression tree and looks up its cached shape on every call; the compiled version skips both steps entirely, because GetByIdCompiled already is that specific, fixed plan.
Imagine a high-throughput pricing API sitting in front of an e-commerce checkout flow — one specific, simple, parameterized lookup ("get the current active price for this product SKU") called tens of thousands of times per minute at peak traffic, on a path where every microsecond of latency compounds across a huge number of calls. This is precisely the profile compiled queries are built for: one fixed, simple shape, an enormous call volume, and a measured, real cost worth shaving.
public class PricingService
{
private static readonly Func<AppDbContext, string, Task<decimal?>> GetCurrentPrice =
EF.CompileAsyncQuery((AppDbContext context, string sku) =>
context.Prices
.Where(p => p.Sku == sku && p.IsActive)
.Select(p => (decimal?)p.Amount)
.FirstOrDefault());
private readonly AppDbContext _context;
public PricingService(AppDbContext context) => _context = context;
public Task<decimal?> GetPriceAsync(string sku) => GetCurrentPrice(_context, sku);
}Contrast that with a typical admin back-office screen's "search products by name, category, and price range" query — a shape that varies per search (different filters get combined depending on what the user typed), runs at a fraction of the pricing lookup's frequency, and is exactly the kind of query where reaching for EF.CompileQuery would be premature: more code, a fixed shape that doesn't even fit a variable search form well, for a saving too small and too infrequent to ever show up in a benchmark.
Automatic query-shape caching is like your phone remembering a contact's number once you've dialed it before — you still open the dialer, type the name, let it match against your saved contacts, and place the call. Fast, but a few small steps every time.
EF.CompileQuery is a speed-dial button, programmed once: press it, and the call happens immediately, no dialer, no name lookup, no matching step at all. Genuinely faster per call — but only worth programming a speed-dial button for a number you call constantly. Programming one for a number you call twice a year is wasted setup for a saving you'll never actually notice.
This is exactly backwards, and it's the single most important thing to get right from this lesson. Automatic shape caching (previous lesson) already avoids full re-translation for every ordinary LINQ query, with zero opt-in. Compiled queries don't turn on caching that would otherwise be absent — they eliminate a smaller, additional per-call cost (expression-tree building and cache lookup) on top of caching that was already happening.
"Often" isn't the bar — "often enough that a small, per-call, CPU-side cost is your actual, measured bottleneck" is. A query called a few hundred times a minute against a database round trip that itself takes several milliseconds will never show a measurable difference from compiling it — the round trip dominates the total time by orders of magnitude. Compiled queries only pay off when the per-call overhead being eliminated is a meaningful fraction of the total cost, which in practice means very simple, very fast queries called an enormous number of times.
Wrapping every repository method in a compiled query up front, before ever profiling or benchmarking anything, on the assumption that it's simply "the fast way to write EF Core queries." This adds real complexity — every varying value has to become an explicit lambda parameter, the shape becomes fixed, and it's genuinely harder to read — for a saving that, on most queries, would never register on any benchmark. Write plain LINQ by default, and reach for a compiled query only after profiling identifies a specific, extremely hot, simple query as worth the extra code — exactly the Part V workflow of profile first, then verify a fix with BenchmarkDotNet.
Calling EF.CompileQuery itself inside a method that runs per-request, re-paying the one-time compilation cost every single call — which defeats the entire point and can make things slower than the plain, automatically-cached alternative. Store the result of EF.CompileQuery/EF.CompileAsyncQuery as a static readonly field, compiled exactly once for the application's whole lifetime.
Trying to compile a search query where different optional filters get conditionally added depending on user input — the whole appeal of Where clauses built up conditionally in code doesn't fit a single fixed compiled shape at all. Reserve compiled queries for genuinely fixed shapes — the varying part should be limited to parameter values (an id, a SKU, a date), never the query's structure itself.
| Situation | Reach for a compiled query? |
|---|---|
| A typical CRUD endpoint, admin screen, or report — called occasionally to moderately often | No — plain LINQ, automatic caching already covers it |
| A search query with conditionally-built filters that vary in shape between calls | No — the shape itself isn't fixed, which compiled queries require |
| A specific, simple, parameterized query, profiled and confirmed to run extremely often on a measured hot path | Yes — after benchmarking confirms a real, worthwhile difference |
| You suspect a query is "probably called a lot" but haven't measured it | No — profile first (233); don't optimize on a guess |
You've seen what compiled queries actually add on top of what EF Core already does automatically, and when that narrow addition is worth the code. Let's confirm it clicked.
1. Without ever calling EF.CompileQuery, does an ordinary context.Products.Where(p => p.Id == id).FirstOrDefault() query get re-translated into SQL from scratch on every single call?
Correct: B
Why B is correct: EF Core's internal automatic shape caching already avoids full re-translation for ordinary queries, with zero opt-in. Compiled queries are a narrower, additional optimization on top of that, not the mechanism that first introduces caching.
Why A is incorrect: This is the exact misconception the lesson calls out — automatic caching already handles re-translation avoidance before compiled queries enter the picture at all.
Why C is incorrect: Automatic shape caching applies to LINQ-to-EF-Core queries generally, not to one specific method like FirstOrDefault.
Why D is incorrect: The database's own query-plan caching is a separate, real thing, but EF Core's internal shape caching is also real and happens independently, on the .NET side, before any SQL is even sent.
Reinforcement: Get the layering right — automatic caching is the baseline; compiled queries are a narrow addition on top, not a replacement for an otherwise-missing feature.
2. A developer calls EF.CompileAsyncQuery(...) fresh, inline, at the top of a method that runs once per incoming HTTP request. What's wrong with this?
Correct: B
Why B is correct: The whole benefit of a compiled query comes from paying the compilation cost exactly once and reusing the resulting delegate many times. Recreating it on every request pays that cost repeatedly, defeating the purpose — and can leave you worse off than simply relying on automatic shape caching.
Why A is incorrect: The recommended pattern is to store the compiled delegate once, typically as a static readonly field, precisely so this per-call recompilation never happens.
Why C is incorrect: There's no such restriction — the actual problem is inefficiency from repeated recompilation, not a hard limit on call count.
Why D is incorrect: EF.CompileAsyncQuery exists specifically for async usage — the example in this lesson uses it correctly inside async repository methods.
Reinforcement: Compile once, store the delegate, call it many times — recompiling per call is a real, common mistake that erases the benefit entirely.
3. Which scenario is the strongest, most honest candidate for EF.CompileQuery, based on this lesson's guidance?
Correct: B
Why B is correct: This matches every criterion the lesson lays out: a fixed, simple query shape, an extremely high call volume on a genuine hot path, confirmed by profiling, with a benchmark actually verifying the gain — exactly the honest bar for reaching for this tool.
Why A is incorrect: Low call frequency and a variable filter shape are both disqualifying — compiled queries need a fixed shape and a genuinely hot path, neither of which this scenario has.
Why C is incorrect: Applying it everywhere as a blanket standard is explicitly called out as a mistake — most queries never benefit enough to justify the added complexity.
Why D is incorrect: A query that runs once has no repeated per-call cost to eliminate — there's nothing for compilation to save here at all.
Reinforcement: Fixed shape, extreme call volume, and measured verification are all required together — not any one of them alone.
You've closed the loop 203 opened — you now know exactly what compiled queries add, and exactly how narrow that addition is. Next up: transactions, taken to the depth Intermediate deliberately left for later.
dotnetmadeeasy.com — Learn C# and .NET, the right way.