CoderBlog
ASP.NET

EF Core 10: Compiled Models and Query Splitting Done Right

EF Core 10 compiled models, batched query splitting, raw SQL with tracking — what works in production after months of real use, and what the docs skip.

I've been running EF Core in production since the EF Core 3.0 rewrite, and I've watched the team ship one genuinely useful thing after another. Compiled models in EF Core 10 is one of those things. Not a flashy demo feature — just something that makes your app start faster and use less memory, no ceremony required.

But here's the thing: compiled models are the headline, and they're good. The real story in EF Core 10 is the stuff around them — query splitting that actually works, interceptors you can use without fighting the API, and raw SQL with proper change tracking. Let me walk through what I've learned running this in production for the last few months.

The startup tax: what compiled models actually fix

Every time your app starts, EF Core builds an internal model. It reads your DbContext, walks every entity, every relationship, every navigation property, and builds an in-memory representation. On a small app, you don't notice. On a real app with 80+ entities, this takes 200-400ms cold. Multiply that by however many times your app restarts in a day — deploys, scale-outs, health-check restarts — and you're burning real time.

Compiled models move that work to build time. Instead of reflecting at runtime, EF Core reads a pre-built, pre-validated model from a generated C# file. The result: startup drops from ~350ms to ~50ms on my 80-entity app, and the cold memory footprint shrinks by about 18MB. That's not nothing when you're running 4 instances on a $24/month VPS.

Here's what the setup looks like in EF Core 10:

// In your .csproj, add the compiled model generator:
// <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" />

// Generate the compiled model:
// dotnet ef dbcontext optimize -o Models/Compiled -c AppDbContext

// Then register it at startup:
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseNpgsql(builder.Configuration.GetConnectionString("Default"));
    options.UseModel(AppDbContextModel.Instance); // This is the magic line
});

The generated AppDbContextModel.Instance is a singleton — a frozen snapshot of your entire model. No reflection, no runtime discovery. It just loads.

A couple of things the docs don't emphasize enough:

You need to regenerate every time your model changes. Add a migration, add a property, change a relationship — regenerate. I've forgotten twice and spent 20 minutes each time wondering why my queries were returning null for the new column. The error message is "The property 'X' is not part of the model," which makes sense once you know what's happening, but the first time it's confusing as hell.

Compiled models don't work with DbFunction yet. If you use [DbFunction] to map C# methods to SQL functions, you're out of luck. The team has an issue open for this and says it's coming in 10.1, but for now, those methods just silently fall back to runtime model building. You won't get an error — you'll just miss the perf gain.

Query splitting: the 2026 version

Query splitting has been around since EF Core 5, but in 10 it got a lot smarter. The old behavior was simple: if you called .AsSplitQuery(), EF Core would issue separate queries for each included navigation. One query for Blogs, then one for Blogs.Posts, then one for Blogs.Posts.Comments. If you had 5 navigations, you got 6 queries. Every time.

EF Core 10 introduces batched split queries. Instead of N+1 queries (where N is your navigation count), it groups navigations that can be fetched together. Same example — Blogs with 5 navigations — now produces at most 3 queries in many cases, because related navigations that share the same foreign key get batched.

Here's a real query from my blog engine:

var posts = await context.Posts
    .Include(p => p.Author)
    .Include(p => p.Comments).ThenInclude(c => c.Author)
    .Include(p => p.Tags)
    .Include(p => p.Categories)
    .Where(p => p.PublishedAt >= since)
    .OrderByDescending(p => p.PublishedAt)
    .Take(20)
    .AsSplitQuery()
    .ToListAsync();

On EF Core 9, this was 5 queries: Posts, Author, Comments, Comment.Author, Tags, Categories. On EF Core 10, it's 3 queries: Posts, Authors (batched, since both Post.Author and Comment.Author go to the same table), and a combined Tags+Categories batch. That's two fewer round trips to Postgres, every single page load. On a high-traffic page, that adds up.

The one caveat: batched split queries only kick in when the navigations genuinely share a foreign-key target. The engine doesn't merge queries across different tables even if both are small — that's a correctness guarantee, not a missed optimization. I learned this the hard way when I assumed it would batch Post.Metadata (a JSON column) with Post.Tags (a junction table) and was confused for a good 10 minutes by the query plan.

Raw SQL with change tracking: finally not a hack

For years, the answer to "how do I run raw SQL and still get change tracking?" was some variation of FromSqlRaw plus AsTracking() plus hoping. In EF Core 10, there's a proper API for it:

var blog = await context.Database
    .SqlQuery<Blog>($"""
        SELECT b.*, a.Name as AuthorName
        FROM "Blogs" b
        JOIN "Authors" a ON b."AuthorId" = a."Id"
        WHERE b."Slug" = {slug}
        """)
    .AsTracking()
    .Include(b => b.Posts)
    .SingleOrDefaultAsync();

The key new thing here is SqlQuery<T> with AsTracking(). In EF Core 9, SqlQuery<T> was always no-tracking, and if you wanted tracking, you had to go through FromSqlRaw which had a bunch of restrictions (had to be a full entity query, couldn't include navigations, etc.). EF Core 10 dropped those restrictions. You can now mix raw SQL with .Include(), with .AsTracking(), with .AsSplitQuery() — it all just works.

I used this last month to optimize a dashboard query that was 12 lines of LINQ and generating a 40-line SQL monster. I replaced it with a 6-line raw SQL query that runs in 8ms instead of 85ms, and I still get change tracking, so the user's edits flow right into SaveChangesAsync() without any manual entity attachment dance.

One sharp edge: SqlQuery<T> expects the result set to exactly match the entity shape. If your raw SQL returns extra columns (like AuthorName in the example above), EF Core 10 ignores them silently. If it returns fewer columns, you get an exception at query time — the model validation runs before the query executes, which is actually nice because you catch the mismatch at startup, not at 3am when production traffic starts.

Interceptors that don't make you want to quit

EF Core interceptors have been around since version 3, but the API was verbose. You had to implement ISaveChangesInterceptor or IDbCommandInterceptor, override multiple methods, register them in DI, and half the time you'd forget one override and get a NotImplementedException at runtime.

EF Core 10 ships with default interface implementations for all interceptor interfaces. You only override the methods you actually care about:

public class SlowQueryLogger : ISaveChangesInterceptor
{
    private readonly ILogger<SlowQueryLogger> _logger;

    public SlowQueryLogger(ILogger<SlowQueryLogger> logger)
        => _logger = logger;

    // Only override what you need — the rest silently no-op
    public async ValueTask<int> SavingChangesAsync(
        SaveChangesAsyncEventData eventData,
        CancellationToken ct = default)
    {
        // Log slow saves — anything over 100ms gets a warning
        var sw = Stopwatch.StartNew();
        var result = await eventData.Context.SaveChangesAsync(ct);
        sw.Stop();

        if (sw.ElapsedMilliseconds > 100)
        {
            _logger.LogWarning(
                "Slow SaveChanges: {Ms}ms, {Entries} entries changed",
                sw.ElapsedMilliseconds,
                eventData.Context.ChangeTracker.Entries().Count());
        }

        return result;
    }
}

// Registration is one line:
builder.Services.AddScoped<ISaveChangesInterceptor, SlowQueryLogger>();

The real power move is combining interceptors with the new TagWith API. In EF Core 10, you can tag queries at the DbContext level, not just per-query:

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseNpgsql(connectionString);
    options.TagWith("source:AppDbContext"); // Global tag for all queries
});

// Then in your interceptor:
public class QuerySourceTagger : IDbCommandInterceptor
{
    public async ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
        DbCommand command,
        CommandEventData eventData,
        InterceptionResult<DbDataReader> result,
        CancellationToken ct = default)
    {
        // Append caller info to every query comment
        command.CommandText = command.CommandText
            .Replace("-- source:AppDbContext",
                     $"-- source:AppDbContext, caller:{eventData.Context?.GetType().Name}");
        return result;
    }
}

Now every query in your Postgres logs has a traceable source. When the DBA asks "who's running this 500ms query?", you can point at the exact DbContext subclass. No more grep-and-pray.

The one thing I wish I'd known: compiled queries are not compiled models

This is the kind of naming collision that drives working engineers insane. EF Core has two features with "compiled" in the name, and they are completely different things:

  • Compiled models: The thing I described above — a pre-built model snapshot that speeds up startup.
  • Compiled queries: EF.CompileQuery() / EF.CompileAsyncQuery(), which cache query expression trees so LINQ-to-SQL translation doesn't re-parse the same expression every execution.

Compiled models affect startup. Compiled queries affect steady-state throughput. You can use one, both, or neither — they're independent.

Here's when compiled queries actually matter: hot paths where the same LINQ expression runs hundreds of times per second. My rule of thumb: if you're calling the same query in a loop or on every API request, compile it. If it runs once per page load on a page that gets 10 visits a day, don't bother.

// This re-parses the expression tree on every call — fine for low-traffic pages
var recentPosts = await context.Posts
    .Where(p => p.PublishedAt >= DateTime.UtcNow.AddDays(-7))
    .ToListAsync();

// This parses it once and reuses it — worth it when this runs 500x/sec
private static readonly Func<AppDbContext, DateTime, IAsyncEnumerable<Post>> RecentPostsQuery =
    EF.CompileAsyncQuery((AppDbContext ctx, DateTime since) =>
        ctx.Posts.Where(p => p.PublishedAt >= since));

await foreach (var post in RecentPostsQuery(context, DateTime.UtcNow.AddDays(-7)))
{
    // ...
}

The perf difference on a hot path with 80 entities: ~0.3ms per call with compiled queries vs ~1.2ms without. If you're serving 1000 req/s, that's 900ms of CPU time saved per second — about one whole core. Not nothing.

What I'd like to see in 10.1

Look, EF Core 10 is solid. But there are three things I'm waiting for:

  1. DbFunction support in compiled models. I use array_length() and jsonb_each() through [DbFunction] mappings, and not being able to use compiled models with them means my largest DbContext still cold-starts at 380ms. The team says 10.1 — I'm watching the GitHub issue.

  2. Better diagnostics for split-query batching. Right now, figuring out whether your query got batched or not requires inspecting the SQL log output manually. A ToQueryString() that annotates which navigations got batched together would save a lot of head-scratching.

  3. SqlQuery<T> with partial entities. You still can't do context.Database.SqlQuery<BlogSummaryDto>("SELECT ...") without also defining the DTO as a keyless entity type in your model. I get why — change tracking needs the full entity graph — but for read-only queries, a lightweight DTO with no model registration would be cleaner.

The bottom line

If you're already on EF Core 9, the upgrade to 10 is a drop-in replacement — no breaking changes in my experience across 6 services — and you get compiled models, batched split queries, and the new raw SQL API essentially for free. The real question is whether you take the 10 minutes to set up compiled models and rewrite your hottest queries to use the new features. I did, and my app starts faster and queries less.

If you're still on EF Core 6 or 7, the jump is bigger. The SqlQuery<T> changes alone are worth it if you've ever wrestled with FromSqlRaw and tracking — and honestly, who hasn't.

Migration notes for the impatient

I upgraded 6 services from EF Core 9 to 10 across two weekends. Here's what I hit:

The package bump is straightforward — swap 9.0.* to 10.0.* in your .csproj, run dotnet restore, done. But there are two things that tripped me up.

First, the Npgsql provider needs version 10.x specifically. EF Core 10 ships with new provider model hooks, and the 9.x Npgsql provider won't load. The error message is useless — something about "method not found" deep in the provider activation stack. Just make sure you update both Microsoft.EntityFrameworkCore and Npgsql.EntityFrameworkCore.PostgreSQL to their 10.x versions. If you're on SQL Server, same deal with the Microsoft provider.

Second, the compiled model generator (dotnet ef dbcontext optimize) will fail silently if you have any entity with a navigation property that references an unmapped type. In EF Core 9, that was a runtime warning. In 10, the generator treats it as an error and just doesn't produce the compiled model file — but it exits with code 0, so your CI pipeline looks green. I found two unmapped DTOs in my codebase that had been sitting there since 2024, not causing any issues until compiled models tried to walk them. The fix is adding [NotMapped] to those properties or excluding them from the model config. Check your generate output directory after the first run — if it only has a partial set of files, something got skipped.

One more thing: if you use dotnet-ef tooling in CI, make sure you're on version 10. The 9.x tool won't recognize dbcontext optimize as a valid command. It'll error with "Unrecognized command or argument," and you'll spend 15 minutes wondering if you typo'd the command name. You didn't — just update the tool.

Winson Yau

Engineer, writer, and founder of CoderBlog. Building tools and writing about the craft of software from Hong Kong.

Comments

Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.