What's New in EF Core 9: UseSeeding, Migration Locking and PendingModelChangesWarning
PendingModelChangesWarning catching a missing migration at Migrate time, the HasData and DateTime.Now trap, UseSeeding/UseAsyncSeeding, migration locking with __EFMigrationsLock, auto-discovered compiled models and EFOptimizeContext, and ExecuteUpdate with complex types in EF Core 9.
Version note: This post covers what EF Core 9 introduced; the code targets EF Core 9.
Guess First
Your app applies migrations itself at startup. We discussed the risks of that in the core post on migrations, but it's a very common pattern in the field:
using var db = new BookstoreContext();
db.Database.Migrate();
db.Authors.Add(new Author { Name = "Ada", Books = { new Book { Title = "EF Notes", PublishedYear = 2020 } } });
db.SaveChanges();
One morning you add public string? Isbn { get; set; } to Book and forget to run dotnet ef migrations add. What happens when the app starts? Does Migrate() add the missing column itself, throw, or act as if nothing happened? And if something throws, on which line?
Released on November 12, 2024, EF Core 9 is an STS release, and most of what it brings is about making exactly this kind of "silent" error visible. EF Core 9 targets net8.0, so you can stay on .NET 8 and move the EF side to 9. I ran the examples in a project targeting net8.0 with EF Core 9.0.22 and dotnet-ef at the same version.
EF Core 8 Stayed Quiet, EF Core 9 Stops You
I ran the same scenario on EF Core 8.0.33 first. Migrate() returned without complaint, and not a single line at warning level hit the log. The error came during SaveChanges:
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 1: 'table Books has no column named Isbn'.
So the error shows up far from its cause. Whoever sees it in production suspects the database first, then digs through the code; it takes a while to realize a migration is missing. On EF Core 9.0.22 the same code stops at the Migrate() line:
InvalidOperationException: An error was generated for warning 'Microsoft.EntityFrameworkCore.Migrations.PendingModelChangesWarning': The model for context 'BookstoreContext' has pending changes. Add a new migration before updating the database. See https://aka.ms/efcore-docs-pending-changes. This exception can be suppressed or logged by passing event ID 'RelationalEventId.PendingModelChangesWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.
The message tells you what to do too: add the migration first. The same check applies to dotnet ef database update. If you put the has-pending-model-changes command from EF Core 8 into CI, you already catch this before deploying; if you didn't, EF Core 9 catches it at the latest when the app starts.
When Nobody Changed the Model
Sometimes this exception shows up in a project where you didn't change anything. We talked about the limits of HasData in the core post on seeding; this is the sneakiest one:
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.Entity<Order>().HasData(new Order { Id = 1, CreatedAt = DateTime.Now });
I added the migration and ran the app right after:
InvalidOperationException: An error was generated for warning 'Microsoft.EntityFrameworkCore.Migrations.PendingModelChangesWarning': The model for context 'BookstoreContext' changes each time it is built. This is usually caused by dynamic values used in a 'HasData' call (e.g. `new DateTime()`, `Guid.NewGuid()`). Add a new migration and examine its contents to locate the cause, and replace the dynamic call with a static, hardcoded value. See https://aka.ms/efcore-docs-pending-changes. This exception can be suppressed or logged by passing event ID 'RelationalEventId.PendingModelChangesWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.
HasData is part of the model. Since DateTime.Now differs on every run, the model is different every time and never matches the migration snapshot. EF Core 8.0.33 let this slide silently too: Migrate() said "migrated", and the next migrations add produced a pointless UpdateData that only updated CreatedAt. The same thing repeats in every new migration. The fix is to give HasData only constant values. If you need dynamic data, HasData isn't the place for it.
UseSeeding and UseAsyncSeeding
EF Core 9 opens an official place for exactly that job. Seeding code goes into the context options and is triggered when EnsureCreated, Migrate or dotnet ef database update runs:
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options
.UseSqlite("Data Source=bookstore.db")
.UseSeeding((context, _) =>
{
if (!context.Set<Author>().Any())
{
Console.WriteLine("seeding...");
context.Set<Author>().Add(new Author
{
Name = "Ada",
Books = { new Book { Title = "EF Notes", PublishedYear = 2020 } }
});
context.SaveChanges();
}
})
.UseAsyncSeeding(async (context, _, cancellationToken) =>
{
if (!await context.Set<Author>().AnyAsync(cancellationToken))
{
Console.WriteLine("seeding async...");
context.Set<Author>().Add(new Author
{
Name = "Ada",
Books = { new Book { Title = "EF Notes", PublishedYear = 2020 } }
});
await context.SaveChangesAsync(cancellationToken);
}
});
I ran the app twice in a row with Migrate(), then with EnsureCreatedAsync() on an empty database:
seeding...
run 1: authors = 1
run 2: authors = 1
seeding async...
authors = 1
Watch three things. First, the "is there data already?" check is your job. EF calls seeding on every Migrate; without the check, the second run would have produced a second Ada. Second, the synchronous method calls the synchronous callback and the async method calls the async one. That's why the docs recommend writing both. Third, unlike HasData, you use normal entities and navigations here; you don't have to supply keys, and DateTime.Now is fine.
Migration Locking
If your app starts with three instances, all three call Migrate() at the same time. EF Core 9 adds a database-level lock for that. These are the relevant commands from the log of the first run above:
CREATE TABLE IF NOT EXISTS "__EFMigrationsLock" (
"Id" INTEGER NOT NULL CONSTRAINT "PK___EFMigrationsLock" PRIMARY KEY,
"Timestamp" TEXT NOT NULL
);
INSERT OR IGNORE INTO "__EFMigrationsLock"("Id", "Timestamp") VALUES(1, '2026-11-03 22:06:06.92746+00:00');
SELECT changes();
DELETE FROM "__EFMigrationsLock";
On SQLite the lock is a one-row table. The instance that manages to insert the row holds the lock, runs the migrations and the seeding, and deletes the row at the end. The others wait in the meantime. In the log, the seeding INSERTs also appear before the DELETE, so seeding runs inside the lock too. The SQL Server provider uses sp_getapplock for the same job instead of a table.
The weak spot on SQLite is that if the process dies before releasing the lock, the row stays in the table. I tried this by inserting the row by hand: Migrate() didn't throw, it just waited. After two minutes I stopped the process myself. The fix is to delete the row from the __EFMigrationsLock table by hand.
Compiled Models Are Discovered Automatically
Compiled models, which shorten startup time for models with hundreds of entities, have existed since EF Core 6. But after running dotnet ef dbcontext optimize you had to add the options.UseModel(...) line by hand. In EF Core 9 the command's output is:
Build started...
Build succeeded.
Successfully generated a compiled model, it will be discovered automatically, but you can also call 'options.UseModel(BookstoreContextModel.Instance)'. Run this command again when the model is modified.
The "discovered automatically" part is an assembly attribute inside one of the generated files: [assembly: DbContextModel(typeof(BookstoreContext), typeof(BookstoreContextModel))]. As long as the context and the compiled model live in the same assembly, you don't need to write UseModel. I checked with db.Model.GetType(): with the CompiledModels folder present it returned BookstoreContextModel, and after deleting it, RuntimeModel.
There's one more step: tying it to the build instead of rerunning the command every time the model changes. That takes the Microsoft.EntityFrameworkCore.Tasks package and two MSBuild properties:
<PropertyGroup>
<EFOptimizeContext>true</EFOptimizeContext>
<EFScaffoldModelStage>build</EFScaffoldModelStage>
</PropertyGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tasks" Version="9.0.22">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
dotnet build now prints an "Optimizing DbContext..." line and writes the compiled model under obj as files like BookstoreContextModel.g.cs. I also saw this warning in the build output: "NativeAOT support is experimental and can change in the future." You get it even if the project doesn't use NativeAOT; the MSBuild task that prints it is the same task that also drives EF Core 9's experimental precompiled query support.
Improvements on the Query Side
Remember the complex types from EF Core 8. When I tried to change the shipping address of orders in bulk with ExecuteUpdate, EF Core 8.0.33 said:
InvalidOperationException: The LINQ expression 'DbSet<Order>()
.Where(o => o.ShippingAddress.City == "London")
.ExecuteUpdate(s => s.SetProperty<Address>(
propertyExpression: o => o.ShippingAddress,
valueExpression: __newAddress_0))' could not be translated. Additional information: The following lambda argument to 'SetProperty' does not represent a valid property to be set: 'o => o.ShippingAddress'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.
On EF Core 9 the same code works and expands the complex type into its columns:
var newAddress = new Address("10 New Rd", "London", "N2");
var count = db.Orders
.Where(o => o.ShippingAddress.City == "London")
.ExecuteUpdate(s => s.SetProperty(o => o.ShippingAddress, newAddress));
UPDATE "Orders" AS "o"
SET "ShippingAddress_City" = @__complex_type_newAddress_0_City,
"ShippingAddress_PostCode" = @__complex_type_newAddress_0_PostCode,
"ShippingAddress_Street" = @__complex_type_newAddress_0_Street
WHERE "o"."ShippingAddress_City" = 'London'
GroupBy over a complex type works now too. There are also two tools for managing parameterization by hand. EF.Constant, which arrived in EF Core 8.0.2, embeds a value that would normally be a parameter into the SQL as a constant; EF Core 9's EF.Parameter does the opposite. If you want to turn off the json_each behavior we saw in the EF Core 8 post for the whole context:
options.UseSqlite("Data Source=q.db", o => o.TranslateParameterizedCollectionsToConstants());
With this setting, ids.Contains(o.Id) came out as WHERE "o"."Id" IN (1, 2) again. On SQL Server it gives the same result without needing the UseCompatibilityLevel(120) trick from EF Core 8. A small addition: the Order() and OrderDescending() operators that came with .NET 7 are now translated to SQL.
Moving to EF Core 9
In a .NET 8 project, upgrading the packages without touching the TFM is enough:
<TargetFramework>net8.0</TargetFramework>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.22" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.22">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
One of the two changes the docs mark as "High" is the PendingModelChangesWarning we just saw. The other is calling Migrate() inside a transaction you opened yourself: since the lock can't be acquired, the docs say this throws. When I tried the same pattern on SQLite with EF Core 9.0.22, I didn't get an exception; MigrationsUserTransactionWarning went to the log as a warning and the migrations were applied without a lock. Whatever the behavior, drop this pattern; EF already manages the transaction itself.
The docs also mention a problem that came with .NET SDK 9.0.200: if the Design package is added with PrivateAssets as above, the EF tools can't find it. I didn't hit this with the 10.0.402 SDK, but if you see "Could not load file or assembly 'Microsoft.EntityFrameworkCore.Design'", this is why. Also, EF Core 9 applies all pending migrations in a single transaction; EF Core 10 reverted that and put each migration back in its own transaction.
On support, Microsoft extended STS releases from 18 to 24 months, and EF Core 9 is the first release to benefit. The result is interesting: EF Core 9's support ends on the same day as the LTS EF Core 8, November 10, 2026.
Common Pitfalls
First, silencing PendingModelChangesWarning. ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) is one line and tempting, but what you're doing is going back to EF Core 8's silent behavior. If you saw the exception, add a migration first and look inside; if it isn't empty you forgot something, and if it keeps changing there's a dynamic value in HasData.
Second, not checking for existing data in the seeding callback. UseSeeding doesn't mean "runs once", it means "runs on every Migrate and EnsureCreated". Without the check, every deploy duplicates the data.
Third, writing only one of them. If the app calls MigrateAsync and you only wrote UseSeeding, seeding never runs. dotnet ef database update, when I tried it, called the synchronous one.
Fourth, forgetting about the automatic compiled model. If the model changes and the output of dotnet ef dbcontext optimize isn't regenerated, the app uses the old model. If you generated it by hand, rerun the command after every model change or use the build integration.
A Small Experiment
★☆☆ Add a property in your own project, call Migrate() without adding a migration and see the exception. Then run dotnet ef migrations has-pending-model-changes and note its output.
★★☆ Write UseSeeding and UseAsyncSeeding and put a different Console.WriteLine in each. Run with Migrate, MigrateAsync, EnsureCreated and dotnet ef database update separately and build a table of which one calls which callback.
★★★ Start two apps that call Migrate() at the same time from two terminals; put a long-running SQL statement, the equivalent of a Thread.Sleep, inside one of the migrations. Watch the __EFMigrationsLock table from a third terminal and observe when the second instance continues.
EF Core 9's support ends on the same day as EF Core 8's. In the next post we move to EF Core 10 LTS, the successor to both: named query filters, LeftJoin, and complex types mapped to JSON.