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 types | Model load, built at runtime | Model load, compiled | First query, runtime | First query, compiled | Assembly size, runtime / compiled |
|---|---|---|---|---|---|
| 10 | 185 ms | 65 ms | 277 ms | 171 ms | 23 KB / 43 KB |
| 100 | 280 ms | 92 ms | 383 ms | 213 ms | 158 KB / 342 KB |
| 500 | 714 ms | 215 ms | 868 ms | 401 ms | 888 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 source | Model load | First query |
|---|---|---|
dotnet ef dbcontext optimize | 92 ms | 213 ms |
dotnet ef dbcontext optimize --nativeaot | 231 ms | 394 ms |
EFScaffoldModelStage=build | 235 ms | 393 ms |
| No compiled model | 280 ms | 383 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:
- Global query filters block generation entirely. One
HasQueryFilteranywhere in the model anddotnet ef dbcontext optimizestops withThe entity type 'Entity1' has a query filter configured. Compiled model can't be generated, because query filters are not supported.The tracking issue, dotnet/efcore#24897, is still open in the backlog. If you use soft delete or multi-tenancy via query filters, compiled models are off the table today. - The model must be regenerated by hand (CLI route) any time an entity, a configuration call or a convention changes. See the drift section above.
- Custom
IModelCacheKeyFactoryimplementations are not supported. If your model varies per tenant or per schema, generate one compiled model per variant into separate folders and namespaces, and pick one withUseModel(...)from runtime state, as the Learn page shows. - Value converters that reference private methods cannot be generated; make the methods
internalorpublic. EnsureCreatedand migrations still runOnModelCreating. CallingDatabase.EnsureCreated()with a compiled model in place incremented myOnModelCreatingcounter to 1, because schema operations need the full design-time model, not the trimmed runtime one. A compiled model speeds up queries andSaveChanges, not your startup schema creation. If a test suite callsEnsureCreatedon every fixture, do not expect it to get faster.- Lazy-loading and change-tracking proxies are still in the Learn list, but the tracking issue, dotnet/efcore#24902, was closed for EF Core 7.0. I did not retest proxies for this post, so treat that bullet as stale rather than as verified support.
- Partial
Customize()methods in the generated model work with the CLI route but not with the MSBuild route, because the project must compile before the model exists.
A decision rule that holds up
Putting the measurements and the limitations together:
- Publishing with Native AOT? You need it. Set
PublishAot, referenceMicrosoft.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. - 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.
- Long-running server that warms up before traffic? Skip it. A startup warm-up that touches
DbContext.Modelgets you the same user-facing result with zero generated code to maintain. - Using
EFOptimizeContextfrom 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 toEFScaffoldModelStage=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.
Related
- How to warm up EF Core’s model before the first query, the zero-maintenance alternative for long-running servers.
- Fix: PublishAot plus EFOptimizeContext exhausts memory during an EF Core build, for the EF Core 10 build bug and the EF Core 11 removal error.
- How to use compiled queries with EF Core for hot paths, the query-side counterpart to a compiled model.
- What is a shadow property in EF Core 11, useful when reading a generated
EntityType.csfile and wondering where an extra property came from.
Sources
- Compiled models, EF Core docs, including the limitations list.
- EF Core MSBuild tasks, EF Core docs, for
EFScaffoldModelStage,EFPrecompileQueriesStageand the NativeAOT code note. - EF Core 11 breaking changes:
EFOptimizeContextMSBuild property has been removed. - What’s new in EF Core 9: auto-compiled models, for automatic discovery and the original MSBuild integration.
dotnet ef dbcontext optimize, CLI reference for--output-dir,--namespace,--nativeaotand--precompile-queries.- dotnet/efcore#24897 (query filters in compiled models, open) and dotnet/efcore#35079 (removal of
EFOptimizeContext).
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.