Migrating from EF Core 8 to EF Core 10 in ASP.NET Core: A Step-by-Step Guide

Most teams I work with stayed on EF Core 8 because it was the LTS release, and now they are looking at EF Core 10, the next LTS, and want to skip 9 entirely. That instinct is right, but it hides a trap: migrating from EF Core 8 to EF Core 10 means your app inherits every EF Core 9 breaking change on top of the EF Core 10 ones, because EF Core 10 keeps the EF9 behavior. The upgrade itself is a one-line version bump in your .csproj. The work is everything that shifts underneath it - a couple of exceptions that now throw at startup, a query translation that changes shape, and a few SQL Server defaults that can generate a migration you did not expect. This is the step-by-step path I actually walk teams through, with the specific changes that bite.
I have run this upgrade on real production APIs, and the pattern that saved us every time was doing it in a branch with a full diff of the generated migration before touching a database. If you want the complete, runnable upgrade checklist - the branch strategy, the rollback script, and a sample project pinned at each stage - the annotated version lives on Patreon. Getting EF Core right in a real API also means getting migrations, MigrateAsync at startup, and the read-query pitfalls right at the same time, which is exactly what Chapter 3 of the Zero to Production course walks through inside a complete ASP.NET Core codebase you can run.
Why Upgrade to EF Core 10 at All
EF Core 10 is the long-term-support release in the .NET 10 wave, so it is the version you want to sit on for the next few years. Beyond support, it brings genuinely useful query and provider improvements, which our rundown of EF Core 10 features covers in detail. The point of this guide is not the features - it is getting there without a broken deploy. If you are already comfortable with the new surface, skip ahead to the step-by-step path.
One framing that helps: EF Core 9 targets .NET 8, and EF Core 10 targets .NET 10. Skipping 9 is fine and supported, but the compiler and runtime will not warn you that you are also absorbing EF9's behavior changes. Treat this as an 8-to-9-to-10 migration compressed into one step.
The Breaking Changes That Actually Bite
Most of the entries on the official lists are low-impact. A handful are not. These are the ones I have seen break real applications.
Two EF Core 9 Exceptions You Inherit at Startup
The first is the one that catches everyone. Starting in EF Core 9, if your model has pending changes that no migration covers, MigrateAsync throws instead of quietly moving on:
The model for context 'AppDbContext' has pending changes. Add a new migration before updating the database.
The usual cause is not a forgotten migration at all. It is non-deterministic seed data - HasData with DateTime.Now, DateTime.UtcNow, or Guid.NewGuid() - which makes EF think the model changed on every build. The fix is to replace dynamic seed values with static, hardcoded ones, or move to the newer seeding hooks. If you genuinely need to suppress it (for example, you manage schema outside EF), do it explicitly:
options.ConfigureWarnings(w =>
w.Ignore(RelationalEventId.PendingModelChangesWarning));
The second is subtler. If you apply migrations at startup with a resilient wrapper - a CreateExecutionStrategy around an explicit transaction - EF Core 9 and later throw MigrationsUserTransactionWarning, because EF now manages the transaction and execution strategy itself:
// EF Core 8: the common "resilient migrate" pattern - now throws
await db.Database.CreateExecutionStrategy().ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
await db.Database.MigrateAsync();
await tx.CommitAsync();
});
// EF Core 10: let EF own the transaction and retries
await db.Database.MigrateAsync();
If you had that wrapper - and a lot of production startup code does - it will fail on the first boot after the upgrade, not in a test. That is why this migration belongs in a branch with a real database run.
How Does EF Core 10 Change the SQL for Contains Queries?
This is the EF Core 10 change most likely to affect performance. A Contains over a parameterized collection used to be translated with a JSON array parameter and OPENJSON. In EF Core 10 it becomes multiple scalar parameters by default:
int[] ids = [1, 2, 3];
var blogs = await db.Blogs.Where(b => ids.Contains(b.Id)).ToListAsync();
// EF Core 8/9 SQL: WHERE [b].[Id] IN (SELECT [value] FROM OPENJSON(@ids))
// EF Core 10 SQL: WHERE [b].[Id] IN (@ids1, @ids2, @ids3)
The new form gives the query planner cardinality information and produces better plans in most cases. But if you tuned around the OPENJSON shape - or you pass very large collections - you can see a regression. You can revert globally or per query without rewriting logic:
// Global: keep the EF8/9 JSON-array translation
options.UseSqlServer(conn, o =>
o.UseParameterizedCollectionMode(ParameterTranslationMode.Parameter));
// Per query: force the JSON-array parameter just here
var blogs = await db.Blogs
.Where(b => EF.Parameter(ids).Contains(b.Id))
.ToListAsync();
Because this is a query-plan concern, pair the upgrade with a look at your hot read paths. Our guide on EF Core 10 query performance covers the profiling side.
SQL Server Defaults That Can Generate a Migration
Two EF Core 10 changes can produce a schema migration you did not ask for. If you use UseAzureSql, or a SQL Server compatibility level of 170 or higher, JSON-mapped columns (primitive collections and owned types written with ToJson) now map to the native json type instead of nvarchar(max). Upgrading generates an ALTER on those columns. It applies cleanly, but it is a real schema change, and the native json type does not support every operation nvarchar(max) did (a DISTINCT over a JSON array will fail). If you are not ready, pin the behavior:
options.UseAzureSql(conn, o => o.UseCompatibilityLevel(160));
Separately, complex-type column names are now uniquified, and nested complex-type columns use their full path (for example Complex_NestedComplex_Property). If you map complex types, review the generated migration for renamed columns and set HasColumnName explicitly where you need the old names preserved.
A Few Smaller Ones to Grep For
ExecuteUpdateAsyncnow takes a regular lambda instead of an expression tree. If you dynamically built setters as anExpression, that code will not compile - and the replacement is simpler, with plainifstatements inside the lambda.Connection string Application Name is now auto-injected by EF. In the rare case you mix EF and Dapper against the same database inside a
TransactionScope, the differing connection strings can escalate to a distributed transaction. Mitigate by settingApplication Nameyourself.Microsoft.Data.Sqlite changed
DateTimeOffsethandling to assume UTC (a high-impact change if you use SQLite, including for local integration tests). TheMicrosoft.Data.Sqlite.Pre10TimeZoneHandlingAppContext switch reverts it temporarily.
The Step-by-Step Migration Path
Here is the sequence I follow, in order, and it has never surprised me when done this way.
Branch and bump. Update
Microsoft.EntityFrameworkCore.*(and your provider) to10.0.x, and retarget the project tonet10.0. Do it in an isolated branch.Fix the EF tools invocation. If your project multi-targets with
<TargetFrameworks>, EF Core 10 tools now require you to name one explicitly, or they error out:dotnet ef migrations add UpgradeToEf10 --framework net10.0Build and read the compiler errors. The
ExecuteUpdateAsyncsignature change and any private value-converter methods (compiled models now reference them directly, so private ones fail) surface here.Boot against a scratch database. This is where the two startup exceptions show up. Fix seed determinism and remove the explicit migration transaction wrapper.
Add one migration and diff it. Run
dotnet ef migrations addand read the generated file line by line. UnexpectedALTER COLUMNstatements are your signal that thejsontype or complex-type naming changes touched your schema.Profile the hot queries. Check
Contains-heavy and large-collection queries for the parameterized-collection change before you trust production numbers.
Common Migration Pitfalls
The mistakes I see repeatedly are all about assuming a version bump is inert. Applying the upgrade straight to a shared dev database, so the auto-generated ALTER runs before anyone reviews it. Suppressing PendingModelChangesWarning to make the error go away, instead of fixing the non-deterministic seed that caused it. Skipping the query profiling because "we did not change any code" - the SQL changed even though your LINQ did not. And upgrading the app but forgetting the EF tools now need --framework, which turns a CI migration step red for a reason that looks unrelated.
Verification Checklist Before You Merge
The app boots against a fresh database with
MigrateAsyncand no suppressed warnings you did not consciously accept.The newly generated migration contains only changes you understand - no surprise column
ALTERs.Containsand large-collection queries have been profiled against the EF Core 10 SQL.Integration tests that use SQLite pass, with
DateTimeOffsetvalues verified if you store them.CI migration and publish steps pass, including the
--frameworkflag where needed.
Cross-check anything unclear against the official EF Core 10 breaking changes and EF Core 9 breaking changes lists - since you are skipping 9, read both.
Frequently Asked Questions
Can I Upgrade Directly From EF Core 8 to EF Core 10 and Skip EF Core 9?
Yes, and it is a supported path. EF Core 10 keeps the translations and behavior introduced in EF Core 9, so you do not need to install 9 in between. The catch is that you also inherit every EF Core 9 breaking change, most importantly the pending-model-changes exception and the explicit-transaction-around-migrate exception. Read both the EF9 and EF10 breaking-change lists before you upgrade.
Why Does MigrateAsync Throw a Pending Model Changes Exception After Upgrading?
Because EF Core 9 made it a hard error when the model has changes no migration covers. The most common trigger is non-deterministic seed data - HasData using DateTime.Now, DateTime.UtcNow, or Guid.NewGuid() - which EF reads as a model change on every build. Replace those with static values (or use the newer seeding hooks), or explicitly ignore RelationalEventId.PendingModelChangesWarning if you manage schema outside EF.
Will Migrating From EF Core 8 to EF Core 10 Change My SQL Queries?
It can. The headline change is that a Contains over a parameterized collection now translates to multiple scalar parameters instead of a JSON array with OPENJSON. In most cases the query planner produces better plans, but heavily tuned or very large-collection queries can regress. You can revert globally with UseParameterizedCollectionMode or per query with EF.Parameter(...).
Does Upgrading to EF Core 10 Generate a Database Migration Automatically?
It can, on SQL Server. If you use UseAzureSql or compatibility level 170 or higher, JSON-mapped columns switch from nvarchar(max) to the native json type, and complex-type column naming changes - both can produce an ALTER. Always generate a migration in a branch and read the diff before applying it anywhere shared. Pin the compatibility level to 160 if you are not ready.
How Do I Run EF Tools After Upgrading a Multi-Targeted Project?
Pass --framework. Starting with EF Core 10, running dotnet ef against a project that uses <TargetFrameworks> (plural) requires you to name the framework, for example dotnet ef migrations add Name --framework net10.0. Single-target projects are unaffected. This is a frequent cause of a suddenly-failing CI migration step.
About the Author
I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.
GitHub: codingdroplets
YouTube: Coding Droplets
Website: codingdroplets.com






