Skip to main content
SEMastery
Data Accessintermediate

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.

12 min readUpdated January 26, 2026

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 journey of a normal EF Core query from LINQ to results.

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

Normal
Compiled

Steps

1

Normal call

Key + translate every time

2

Compiled call

Straight to params + SQL

3

Result

Same rows, less CPU

A compiled query skips the per-call translation work.

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 — for async queries (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 readonly field, so it is created only once.
  • We pass the AppDbContext and the id into the delegate. The compiled query does not capture a context; you hand it a fresh one each call.
  • Inside the lambda we use FirstOrDefault, not FirstOrDefaultAsync. The EF.CompileAsyncQuery wrapper 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 styleHelper to useWhat it returns
Synchronous, single rowEF.CompileQueryThe entity or null
Synchronous, many rowsEF.CompileQueryIEnumerable<T>
Async, single rowEF.CompileAsyncQueryTask<T>
Async, many rowsEF.CompileAsyncQueryIAsyncEnumerable<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.

A compiled query only removes the orange CPU work, not the database time.

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

Hot
Fixed
Big tree

Steps

1

Hot path

Runs very often

2

Fixed shape

Structure never changes

3

Big tree

Many operators to translate

Use them only where the gain multiplies.

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.

ApproachIn-memory prep per callCalls per second handled
Normal LINQ queryhigher (key + lookup each time)baseline
Compiled queryabout 30 to 40 percent lower prephigher 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 normal query still computes a cache key each call; a compiled query skips it.

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 DbContext in the lambda. Always pass the context in as a parameter. A captured context would be reused across requests and break, because a DbContext is 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 Where only 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.CompileQuery for sync and EF.CompileAsyncQuery for async. Inside both, write the plain LINQ ending (like FirstOrDefault), 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.

QuestionWhy 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

Quick recap

  • A compiled query translates your LINQ to SQL once and stores it as a reusable delegate.
  • Use EF.CompileQuery for sync code and EF.CompileAsyncQuery for async code.
  • Keep the delegate in a static readonly field, and pass the DbContext in 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