Unleash EF Core Performance With Compiled Queries
Learn EF Core compiled queries in .NET 10 with EF.CompileQuery and EF.CompileAsyncQuery. Simple words, real examples, and clear before-and-after numbers.
Cooking from a recipe you already know
Think about your grandmother making her famous biryani. The first time she tried a new recipe, she read every line slowly. She measured each spice, checked the book, and thought hard about each step. It took a long time.
But after cooking that same biryani a hundred times, she does not open the book at all. Her hands already know the steps. She skips straight to the cooking. The dish comes out the same, but much faster, because she does not waste time re-reading the recipe every single time.
A normal EF Core query is a little like reading the recipe each time. Before it can talk to the database, EF Core must look at your LINQ code, work out what SQL to build, and find the right plan. EF Core is smart and caches a lot of this, but it still does some checking on every call.
A compiled query is like your grandmother who knows the recipe by heart. You ask EF Core to read the recipe just once, remember it, and hand you back a ready-to-use shortcut. After that, every call skips straight to the cooking.
Everything here works on .NET 10 (LTS) with C# 14, and most of it works on older EF Core versions too.
What actually happens when you run a query
To see why compiled queries help, let us look at the steps a normal EF Core query goes through. Not all of them touch the database. Some happen inside your own app, on the CPU.
The first three steps, building the cache key and translating to SQL, happen on your CPU before any message goes to the database. EF Core caches the translated plan, so step C is usually skipped after the first run. But step B still happens every time: EF Core walks your expression tree to make a key, then looks that key up in the cache.
For a tiny query this work is cheap. For a big query with many joins, filters, and projections, the expression tree is large, and building the key takes real time. When that query runs thousands of times per second, all those small costs add up.
A compiled query removes steps B and C from the hot path. You pay for them once, up front. After that, EF Core jumps straight to binding parameters and running the SQL.
Normal vs compiled query path
Steps
Normal call
Key + translate every time
Compiled call
Straight to params + SQL
Result
Same rows, less CPU
What a compiled query is, in plain words
A compiled query is a LINQ query that you turn into a delegate (a small reusable function) ahead of time. EF Core does the translation work once, stores the result, and gives you back that delegate. You keep it in a static field so it lives for the whole life of your app.
Two helper methods make these delegates:
EF.CompileQuery— for normal, synchronous queries.EF.CompileAsyncQuery— forasyncqueries (the common case in web apps).
One important rule: a compiled query has a fixed shape. You can pass values in (like an id or a name), but you cannot change the structure at runtime. You cannot decide "add a Where only on Tuesdays" inside a compiled query. The recipe is locked. That is exactly what makes it fast.
Your first compiled query
Let us start with a simple lookup. Say we have a Customer entity and we very often need to find a customer by id. Here is the normal way most people write it.
// The normal way: EF Core checks the recipe on every call.
public async Task<Customer?> GetCustomerAsync(AppDbContext db, int id)
{
return await db.Customers
.FirstOrDefaultAsync(c => c.Id == id);
}This works fine. But on a busy endpoint that runs millions of times, EF Core rebuilds the cache key on every call. Now here is the compiled version.
public static class CompiledQueries
{
// Compile once. This delegate is built a single time and reused forever.
private static readonly Func<AppDbContext, int, Task<Customer?>> GetCustomerById =
EF.CompileAsyncQuery(
(AppDbContext db, int id) =>
db.Customers.FirstOrDefault(c => c.Id == id));
// Call it like any normal async method.
public static Task<Customer?> GetCustomerAsync(AppDbContext db, int id) =>
GetCustomerById(db, id);
}Look closely at the differences:
- The query lives in a
static readonlyfield, so it is created only once. - We pass the
AppDbContextand theidinto the delegate. The compiled query does not capture a context; you hand it a fresh one each call. - Inside the lambda we use
FirstOrDefault, notFirstOrDefaultAsync. TheEF.CompileAsyncQuerywrapper makes the whole thing async for us.
You call CompiledQueries.GetCustomerAsync(db, 42) and await it, just like before. The result is identical. Only the speed changes.
Sync, async, and lists
You are not limited to single rows. A compiled query can return many rows too. For sync code, EF.CompileQuery can return an IEnumerable. For async code, EF.CompileAsyncQuery returns an IAsyncEnumerable so you can stream the rows.
public static class OrderQueries
{
// Returns a stream of orders for one customer, newest first.
private static readonly Func<AppDbContext, int, IAsyncEnumerable<Order>> RecentOrders =
EF.CompileAsyncQuery(
(AppDbContext db, int customerId) =>
db.Orders
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedOn)
.Take(20));
public static async Task<List<Order>> GetRecentOrdersAsync(AppDbContext db, int customerId)
{
var results = new List<Order>();
await foreach (var order in RecentOrders(db, customerId))
{
results.Add(order);
}
return results;
}
}Here we stream up to 20 orders with await foreach. The query shape is fixed: same Where, same OrderByDescending, same Take. Only the customerId changes. That is the perfect fit for a compiled query.
The table below shows which helper to pick.
| Your code style | Helper to use | What it returns |
|---|---|---|
| Synchronous, single row | EF.CompileQuery | The entity or null |
| Synchronous, many rows | EF.CompileQuery | IEnumerable<T> |
| Async, single row | EF.CompileAsyncQuery | Task<T> |
| Async, many rows | EF.CompileAsyncQuery | IAsyncEnumerable<T> |
Where the speed actually comes from
It helps to be honest about what compiled queries do and do not speed up. They only make the in-memory part of the work faster, the part EF Core does inside your app. They do not change the database or the network at all.
So if your query is slow because the database is missing an index, or because it pulls a million rows over a slow network, a compiled query will not save you. You must fix those first. Compiled queries help when the CPU cost of preparing the query is a real part of the total, which happens with very high request rates and large expression trees.
A good way to picture the win: the saving per call is small, but it is paid on every single call. Multiply a tiny saving by millions of calls and it becomes real CPU and money.
When compiled queries pay off
Steps
Hot path
Runs very often
Fixed shape
Structure never changes
Big tree
Many operators to translate
A realistic before-and-after
Imagine a product lookup endpoint on a busy shopping site. It runs GET /products/{id} thousands of times a second during a sale. The query joins the product, its category, and its price band, then projects into a small DTO. That is a medium-sized expression tree, run on a very hot path. A perfect candidate.
Here is the compiled version with a projection, which is the shape you should aim for in real apps.
public static class ProductQueries
{
private static readonly Func<AppDbContext, int, Task<ProductDto?>> GetProductCard =
EF.CompileAsyncQuery(
(AppDbContext db, int id) =>
db.Products
.Where(p => p.Id == id)
.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Category = p.Category.Name,
Price = p.PriceBand.Amount
})
.FirstOrDefault());
public static Task<ProductDto?> GetProductCardAsync(AppDbContext db, int id) =>
GetProductCard(db, id);
}The numbers below are illustrative, in the range many teams report (the official guidance puts the in-memory saving around 30 to 40 percent). Always measure your own app; do not copy these as facts.
| Approach | In-memory prep per call | Calls per second handled |
|---|---|---|
| Normal LINQ query | higher (key + lookup each time) | baseline |
| Compiled query | about 30 to 40 percent lower prep | higher under load |
Notice the database time is missing from that table on purpose. It is the same for both. Only the CPU prep changes.
How EF Core caching fits in
People sometimes ask: "Does not EF Core already cache queries?" Yes, it does, and this is an important point.
When you run a normal LINQ query, EF Core does not translate it from scratch every time. It builds a cache key from your expression tree and looks for a ready-made plan in its query cache. If it finds one, it reuses it. So you already get most of the benefit for free.
The state diagram below shows the small extra step a compiled query removes.
A compiled query starts at Execute. It already holds the plan in its delegate, so it never builds a key and never looks anything up. That skipped work is the whole gain. It is small for one call, but it never goes away, and on a hot path it repeats forever.
Common mistakes to avoid
Compiled queries are simple once you see them, but a few traps catch beginners. Here are the ones worth remembering.
- Do not capture a
DbContextin the lambda. Always pass the context in as a parameter. A captured context would be reused across requests and break, because aDbContextis not thread-safe. - Do not put a compiled query in a non-static field. If you create the delegate on every request, you lose all the benefit. Use
static readonly. - Do not try to change the shape at runtime. No "add this
Whereonly if a flag is true" inside the delegate. The structure must be fixed. For dynamic filters, a normal query is the right tool. - Do not mix up the helpers. Use
EF.CompileQueryfor sync andEF.CompileAsyncQueryfor async. Inside both, write the plain LINQ ending (likeFirstOrDefault), not the async ending. - Do not compile every query. Most queries do not run often enough to matter. Profile first, then compile only the few hot ones.
A quick checklist before you compile
Ask yourself these questions. If you answer "yes" to all of them, a compiled query is a good idea.
| Question | Why it matters |
|---|---|
| Does this query run very often? | The saving multiplies across calls |
| Is the shape fixed (only values change)? | Compiled queries cannot change structure |
| Is the LINQ tree large or complex? | Bigger trees cost more to translate |
| Have I already fixed indexes and N+1? | Those matter far more than CPU prep |
If any answer is "no", spend your time elsewhere first. The biggest wins almost always come from good indexes, small projections, and avoiding the N+1 problem, not from compiled queries.
Putting it together
A clean pattern is to keep all compiled queries in one static class per area of your app. This makes them easy to find and reuse. Inject your DbContext as usual, then pass it into the compiled delegate from your service or endpoint.
// Endpoint using the compiled query from earlier.
app.MapGet("/products/{id}", async (int id, AppDbContext db) =>
{
var product = await ProductQueries.GetProductCardAsync(db, id);
return product is null ? Results.NotFound() : Results.Ok(product);
});Notice the route uses GET /products/{id}, and the compiled delegate handles only the data part. The endpoint code looks completely normal. That is the nice thing about compiled queries: they speed up the hot path without making your calling code ugly.
References and further reading
- Advanced Performance Topics — EF Core (Microsoft Learn) — the official guide to compiled queries and other advanced tuning.
- Efficient Querying — EF Core (Microsoft Learn) — the basics you should fix before reaching for compiled queries.
- Unleash EF Core Performance With Compiled Queries — Milan Jovanović — a clear community walkthrough with benchmarks.
- Are compiled queries really efficient on EF Core? — Goat Review — an honest look at when they help and when they do not.
Quick recap
- A compiled query translates your LINQ to SQL once and stores it as a reusable delegate.
- Use
EF.CompileQueryfor sync code andEF.CompileAsyncQueryfor async code. - Keep the delegate in a
static readonlyfield, and pass theDbContextin as a parameter. - The shape is fixed: only values can change, not the structure.
- The saving is only in the in-memory CPU prep, around 30 to 40 percent. The database and network time do not change.
- EF Core already caches plans; a compiled query removes the per-call key building and lookup on top of that.
- Use them only on hot paths with big, fixed queries. Fix indexes, projections, and N+1 problems first.
- Measure before and after. Do not over-optimize queries that rarely run.
Related Posts
How to Increase EF Core Performance for Read Queries in .NET
Make EF Core read queries fast in .NET 10 with AsNoTracking, projections, split queries, pagination, indexes, and compiled queries. Simple words and real examples.
EF Core Query Optimization: From 30 Seconds to 30 Milliseconds
Learn EF Core query optimization step by step: fix N+1 queries, use projections, AsNoTracking, indexes, and compiled queries to turn a 30-second query into 30ms.
Understanding Change Tracking for Better Performance in EF Core
Learn how EF Core change tracking works, the entity states it uses, and simple tricks like AsNoTracking to make your .NET apps faster.
EF Core Query Splitting: Fix Slow Queries and Cartesian Explosion
Learn how EF Core query splitting (AsSplitQuery) fixes the cartesian explosion problem with simple examples, diagrams, and real performance numbers. Know when to split and when not to.
Why Every EF Core Developer Needs to Try Entity Framework Extensions
A friendly guide to Entity Framework Extensions: bulk insert, update, delete and merge for EF Core, with simple analogies, diagrams, tables and code examples.
How I Made My EF Core Query Faster With Batching
A beginner-friendly guide to EF Core batching. Learn how SaveChanges groups SQL into fewer database trips, how to tune MaxBatchSize, and when it helps.