Start Debugging

What Is a Compiled Model in EF Core 11, and When Is It Worth Enabling?

A compiled model is generated C# that recreates your EF Core model without running OnModelCreating. Measured on EF Core 11 RC1: model load drops from 714 ms to 215 ms at 500 entities, but the MSBuild route (which replaced EFOptimizeContext) is slower, stale models fail silently, and query filters block generation.

Short answer: a compiled model is C# source code, generated by dotnet ef dbcontext optimize (or by the Microsoft.EntityFrameworkCore.Tasks MSBuild package), that rebuilds your EF Core model directly instead of running conventions and OnModelCreating on the first DbContext use. It is worth enabling when the first query in a fresh process costs you real money or latency (serverless, autoscaled containers, CLI tools, desktop apps) and your model has around 100 entity types or more, or when you publish with Native AOT, where it is mandatory. In EF Core 11 the EFOptimizeContext property is gone: you opt in with EFScaffoldModelStage instead. For a 20-table web API that starts once a week, it is not worth the maintenance.

Every number and error message below comes from runs against Microsoft.EntityFrameworkCore.Sqlite 11.0.0-rc.1.26425.128 and dotnet-ef 11.0.0-rc.1.26425.128 on the .NET 11 RC1 SDK (11.0.100-rc.1.26425.128), Release builds, on an Apple silicon MacBook. One cross-check used EF Core 10.0.12 on SDK 10.0.302.

What EF Core does on the first DbContext use

Creating a DbContext is cheap. The expensive part happens the first time something touches DbContext.Model: a query, Add, SaveChanges, or reading Model yourself. At that point EF Core runs its convention pipeline over every entity type it can reach, discovers properties and relationships by reflection, applies your OnModelCreating configuration, validates the result, and then converts the mutable model into a read-only RuntimeModel. The result is cached per context type (more precisely, per model cache key) for the life of the process, so you pay once per process.

A compiled model skips the pipeline. Instead of discovering the model, EF Core instantiates generated classes that call RuntimeModel.AddEntityType, AddProperty, AddKey, AddForeignKey and so on with the already-resolved answers. Here is a fragment of what dotnet ef dbcontext optimize emitted for one entity in my test project:

// <auto-generated /> by dotnet-ef 11.0.0-rc.1.26425.128
var runtimeEntityType = model.AddEntityType(
    "Bench.Entity1",
    typeof(Entity1),
    baseEntityType,
    propertyCount: 8,
    navigationCount: 1,
    foreignKeyCount: 1,
    unnamedIndexCount: 2,
    keyCount: 1);

var name = runtimeEntityType.AddProperty(
    "Name",
    typeof(string),
    propertyInfo: typeof(Entity1).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
    fieldInfo: typeof(Entity1).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
    maxLength: 200);

The maxLength: 200 came from HasMaxLength(200) in OnModelCreating. That configuration is now baked into the generated code, which is the whole point and also the source of every gotcha later in this post.

The command writes one <Entity>EntityType.cs file per entity type, plus three more: <Context>Model.cs (a RuntimeModel subclass with a static Instance), <Context>ModelBuilder.cs, and <Context>AssemblyAttributes.cs. The last one contains the line that makes EF Core find the model without any code change on your side:

[assembly: DbContextModel(typeof(BenchContext), typeof(BenchContextModel), ProviderName = "Microsoft.EntityFrameworkCore.Sqlite")]

Since EF Core 9, a compiled model in the same assembly as the context is discovered automatically through that attribute. You only need optionsBuilder.UseModel(BenchContextModel.Instance) when the compiled model lives in a different assembly, or when you want to choose between several compiled models at runtime.

Measuring what it buys you

The Microsoft Learn page on compiled models says they help “applications with large models”, meaning “hundreds to thousands of entity types”. That is vague enough to be useless for a decision, so I measured it. A Python script generated models with 10, 100 and 500 entity types. Each entity has eight scalar properties, a nullable FK to the previous entity, a unique index and a HasMaxLength call, so the conventions have real work to do. The program times the first model access and the first query in a fresh process:

// .NET 11, C# 14, EF Core 11.0.0-rc.1.26425.128, Microsoft.EntityFrameworkCore.Sqlite
using System.Diagnostics;
using Bench;
using Microsoft.EntityFrameworkCore;

var sw = Stopwatch.StartNew();
using var ctx = new BenchContext();
var model = ctx.Model;                                     // first touch: build or load the model
var modelMs = sw.Elapsed.TotalMilliseconds;
var n = ctx.Set<Entity1>().Where(e => e.IsActive).Count(); // first query
var firstQueryMs = sw.Elapsed.TotalMilliseconds;

Console.WriteLine($"{model.GetType().Name},{model.GetEntityTypes().Count()}," +
    $"model={modelMs:F0}ms,firstQuery={firstQueryMs:F0}ms," +
    $"OnModelCreating={BenchContext.ModelCreatingCalls}");

BenchContext.ModelCreatingCalls is a static counter incremented inside OnModelCreating, so each run proves which path it took. The database was a pre-created SQLite file. Medians of seven cold process starts per row:

Entity typesModel load, built at runtimeModel load, compiledFirst query, runtimeFirst query, compiledAssembly size, runtime / compiled
10185 ms65 ms277 ms171 ms23 KB / 43 KB
100280 ms92 ms383 ms213 ms158 KB / 342 KB
500714 ms215 ms868 ms401 ms888 KB / 1.8 MB

Two things stand out. First, the saving is real even at 10 entity types, about 110 ms, because a good share of the runtime cost is JIT-compiling the convention pipeline itself, not just running it per entity. Second, it scales: at 500 entity types the compiled model saves half a second on every cold start. OnModelCreating was called zero times in every compiled run and once in every runtime run.

Whether 110 ms matters is a product question. It does not matter for an ASP.NET Core app behind a load balancer that warms up before taking traffic. It does matter for an AWS Lambda or Azure Functions consumption plan, where the cold start is user-visible, for a dotnet tool that runs for two seconds, and for a desktop or MAUI app where the first screen waits on a query.

Where EFOptimizeContext went in EF Core 11

EF Core 9 shipped an MSBuild integration in the Microsoft.EntityFrameworkCore.Tasks package that regenerates the compiled model during build or publish, so it cannot drift from the code. In EF Core 9 and 10 you turned it on with EFOptimizeContext=true and then chose the stage with EFScaffoldModelStage and EFPrecompileQueriesStage.

EF Core 11 removed EFOptimizeContext (dotnet/efcore#35079). The two stage properties now work on their own, and setting the old property fails the build. With PublishAot set to true, generation during publish is on by default. Without AOT, this is the EF Core 11 equivalent of the old opt-in:

<!-- .NET 11, EF Core 11.0.0-rc.1.26425.128 -->
<PropertyGroup>
  <EFScaffoldModelStage>build</EFScaffoldModelStage>
  <EFPrecompileQueriesStage>none</EFPrecompileQueriesStage>
</PropertyGroup>
<ItemGroup>
  <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-rc.1.26425.128" PrivateAssets="all" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Tasks" Version="11.0.0-rc.1.26425.128" PrivateAssets="all" />
</ItemGroup>

The generated files go to obj/<Configuration>/<TFM>/ as *.g.cs and are added to the compilation, so nothing lands in source control. Valid stage values are build, publish and anything else (conventionally none) to disable. The MSBuild tasks reference lists DbContextName, EFTargetNamespace, EFOutputDir and EFNullable for finer control. If you upgraded a project that had the old property and the build exploded, the PublishAot plus EFOptimizeContext post covers that error and the memory-exhaustion bug that preceded it.

So, is the MSBuild route worth enabling? For Native AOT, yes: you need a compiled model and precompiled queries anyway, and regenerating them on publish is the safest way to keep them in sync. For a regular JIT app, my measurements say no, for two reasons.

The MSBuild route generates the slower, AOT-flavoured model

The MSBuild docs note that the integration “will always generate additional code in the compiled model that’s required for NativeAOT”. In practice that means an extra <Entity>UnsafeAccessors.g.cs file per entity type: 203 generated files for my 100-entity model instead of 103. That code is not free at startup. Same 100-entity model, same machine:

Compiled model sourceModel loadFirst query
dotnet ef dbcontext optimize92 ms213 ms
dotnet ef dbcontext optimize --nativeaot231 ms394 ms
EFScaffoldModelStage=build235 ms393 ms
No compiled model280 ms383 ms

The MSBuild-generated model loads only about 45 ms faster than no compiled model at all, and the first query was no faster. The plain CLI output is 2.5 times faster to load. If you are not publishing with AOT, the NativeAOT code is pure overhead.

A clean build fails, and the retry silently skips generation

The second reason is a build ordering problem I hit on both 11.0.0-rc.1 and 10.0.12. The Tasks package hooks generation into TargetsTriggeredByCompilation, which runs right after CoreCompile, but the OptimizeDbContext task loads the assembly from bin/, which is not populated until later in the build. From a clean checkout, with no bin folder, the first build fails:

Optimizing DbContext...
Microsoft.EntityFrameworkCore.Tasks.targets(105,5): error : File '.../bin/Debug/net11.0/Bench.dll' not found.
Build FAILED.

Running dotnet build again then reports Build succeeded, because the compile step is up to date and generation is skipped. The resulting app runs without a compiled model: my test printed RuntimeModel and OnModelCreating=1. Only after touching a source file did the next build print Optimizing DbContext... and produce BenchContextModel. In CI, where every build starts clean, that is a red build on every run. I did not find an issue tracking it in dotnet/efcore at the time of writing, so check the release notes when 11.0 GA ships.

The CLI route: generate once, check it in

For a JIT app the better setup is the old one: run the CLI, commit the output, regenerate when the model changes.

# .NET 11 SDK, dotnet-ef 11.0.0-rc.1.26425.128
dotnet tool install --global dotnet-ef --version 11.0.0-rc.1.26425.128
dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModels

On an already-built project, generation took between 1.3 and 2.3 seconds for my three model sizes. Two details worth knowing: the project must be restored first (otherwise you get Unable to retrieve project metadata), and if the context is configured in a different startup project, pass --startup-project or add an IDesignTimeDbContextFactory<T>.

The risk with the CLI route is drift, and drift is worse than the docs make it sound. The warm-up post from April said EF Core detects a stale compiled model and throws. Testing it on EF Core 11 RC1 says otherwise. I generated a compiled model, then added a Sku property to one entity and renamed a column with HasColumnName("DisplayName") without regenerating:

// .NET 11, EF Core 11.0.0-rc.1.26425.128, compiled model generated BEFORE these changes
Console.WriteLine(ctx.Set<Entity2>().Select(e => e.Name).ToQueryString());
// With the stale compiled model:   SELECT "t"."Name" FROM "T2" AS "t"
// Without any compiled model:      SELECT "t"."DisplayName" FROM "T2" AS "t"

No exception, no warning: the stale model generated SQL for the old column name. The new Sku property did fail, but only when a query used it, with the generic “The LINQ expression could not be translated … Translation of member ‘Sku’ on entity type ‘Entity0’ failed” error, which points you nowhere near the real cause.

The fix is to make drift a CI failure. The generator is deterministic except for one line, the modelId GUID in <Context>ModelBuilder.cs, which changes on every run. Git can ignore that line:

# .NET 11 SDK, dotnet-ef 11.0.0-rc.1.26425.128, git 2.30+
dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModels
git diff --exit-code -I 'modelId:' -- CompiledModels

If someone changed the model without regenerating, the diff is non-empty and the job fails. It is the same idea as checking for pending migrations, and it fits in the same CI step.

What a compiled model cannot do

The Learn page has a limitations list, but it is partly out of date. Verified against EF Core 11 RC1:

A decision rule that holds up

Putting the measurements and the limitations together:

  1. Publishing with Native AOT? You need it. Set PublishAot, reference Microsoft.EntityFrameworkCore.Tasks, and let publish generate the model and precompiled queries. Without it, the first query throws “Model building is not supported when publishing with NativeAOT”, as covered in the MAUI iOS version of that error.
  2. Cold starts are user-visible (serverless, scale-to-zero containers, CLI tools, desktop apps) and you have no query filters? Use the CLI route, commit the output, and add the drift check to CI. Expect roughly 100 ms saved at small model sizes and 500 ms at 500 entity types. It pairs well with the other tricks in reducing cold-start time for a .NET 11 AWS Lambda.
  3. Long-running server that warms up before traffic? Skip it. A startup warm-up that touches DbContext.Model gets you the same user-facing result with zero generated code to maintain.
  4. Using EFOptimizeContext from EF Core 9 or 10 for a JIT app? On the upgrade to EF Core 11, consider deleting the MSBuild integration instead of translating it to EFScaffoldModelStage=build. You get a faster model from the CLI and avoid the clean-build failure.

The model is only half of the first-query cost. Each new LINQ shape also gets translated and compiled once per process; if a handful of hot queries dominate, compiled queries for EF Core hot paths attack that second half.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back