Migrating from ASP.NET Core 10 to 11
Objective
Moving an ASP.NET Core 10 app to 11 compiles for most teams after a change of target framework. The cost is not in the compiler: it is in behavior that changed without a build error, and a few restore failures that look like package problems. This concept is the checklist for that upgrade. It keeps to what I reproduced on the SDK 11.0.100 GA against the .NET 10 SDK on the same code, and labels everything else as coming from the official migration guide.
For the list of what is new rather than what breaks, see
whats-new-in-aspnet-core-11. For the .NET 10 map, see
whats-new-in-aspnet-core-10.
Use Cases
- Planning the upgrade from the .NET 10 LTS to .NET 11 and sizing the test pass.
- Reading a red build or a changed test after retargeting and asking whether the cause is your code or the framework.
- Reviewing a pull request that bumps
TargetFrameworktonet11.0. - Deciding whether to move at all (see
dotnet-support-lifecycle-and-choosing-a-target).
Deep Dive
Retarget, then read the restore
Change <TargetFramework> to net11.0, update the framework packages
(Microsoft.AspNetCore.OpenApi 11.0.0 and so on) and restore. Pin the SDK with
global.json so a teammate on another SDK does not change the result. Two
package facts I checked on nuget.org on 2026-11-12: Microsoft.Extensions.Http.Resilience
now has a stable 11.0.0, and Asp.Versioning.Http has no 11.x yet (latest
10.2.3). A package with no 11.x is not necessarily broken on net11.0, but I did
not run Asp.Versioning there, so read its release notes before you assume.
OpenAPI: a restore error and a new default
If the project pins Microsoft.OpenApi 2.x next to Microsoft.AspNetCore.OpenApi
11.0.0, restore fails, because the new package asks for [3.11.0, 4.0.0):
texterror NU1605: Warning As Error: Detected package downgrade: Microsoft.OpenApi from 3.11.0 to 2.7.5. oa -> Microsoft.AspNetCore.OpenApi 11.0.0 -> Microsoft.OpenApi (>= 3.11.0 && < 4.0.0) oa -> Microsoft.OpenApi (>= 2.7.5)
Remove the pin or raise it to 3.11.0 or higher. The fix is a version bump, but the new major also moves types: code that built a document with 2.x types (transformers, custom schema code) needs a compile pass. With the pin fixed, the generated version changed too:
textAddOpenApi() -> "openapi": "3.2.0" AddOpenApi("v31", o => o.OpenApiVersion = OpenApiSpecVersion.OpenApi3_1) -> "3.1.2"
A snapshot test that expects a 3.1 document or a client generator that
cannot read 3.2 breaks here. Pin OpenApi3_1 until your tools catch up. The
details of the 3.2 differences are in openapi-document-generation.
A BackgroundService that fails now fails RunAsync
Same code, a hosted service that throws from ExecuteAsync, on both SDKs:
csharpclass Boom : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken ct)
=> throw new InvalidOperationException("boom in ExecuteAsync");
}
// host.RunAsync() with AddHostedService<Boom>()text.NET 10: RunAsync completed normally .NET 11: RunAsync threw InvalidOperationException: boom in ExecuteAsync
On .NET 10 RunAsync returned as if nothing went wrong, so Program.cs simply
continued. On 11 the exception reaches the caller, and an unhandled exception
in Program.cs ends the process the way any unhandled exception does (I did
not measure the exit code). For an orchestrator that is the right signal, but
a job that used to fail silently can now restart your container.
The knobs are in hosted-services-and-backgroundservice. One I ran: setting
BackgroundServiceExceptionBehavior.Ignore on HostOptions makes the same host
complete normally on both versions (I cancelled it after 500 ms), and the proper fix is to catch inside
ExecuteAsync what the service can recover from.
Scope disposal reports every failure
When a scope is disposed, Dispose of each scoped service runs. Two scoped
services whose Dispose throws, resolved in one scope:
text.NET 10: InvalidOperationException: D2 dispose failed d1.disposed=False .NET 11: AggregateException (D2 dispose failed) (D1 dispose failed) inner=2 d1.disposed=True
On 10 the first exception escaped and the other service was never disposed. On
11 every service is disposed and all failures come back in an
AggregateException. A catch (InvalidOperationException) around a scope
dispose, or a test asserting the type of the exception, no longer matches, and
logging that printed only e.Message now prints "One or more errors occurred."
first. The upside is that the leak is gone. See
dependency-injection-and-service-lifetimes.
An empty string in chained configuration now wins
A configuration source added with AddConfiguration(otherConfiguration) whose
key holds an empty string:
csharpvar inner = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Timeout"] = "" }).Build();
var cfg = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Timeout"] = "30" })
.AddConfiguration(inner).Build();text.NET 10 .NET 11 cfg["Timeout"] '30' '' cfg.GetValue<int?>("Timeout") 30 null
On 10 the chained empty value was ignored and the earlier 30 survived. On 11
the empty string overrides it, which is how two plain providers
(AddInMemoryCollection twice) already behaved on both versions: the second
empty value wins. The risk is an app that layers a base configuration under an
environment-specific one and used to get a default back by leaving a value
blank. Now it gets an empty string, and GetValue<int?> returns null. Check appsettings files and environment variables that set
Key= with nothing after it. The options side is in
configuration-and-the-options-pattern.
Forms: the CSRF check and the template
A Minimal API form endpoint, [FromForm], no UseAntiforgery(), posted without
any browser headers:
text.NET 10: 500 InvalidOperationException: Endpoint HTTP: POST /transfer contains anti-forgery metadata, but a middleware was not found that supports anti-forgery. .NET 11: 200
The 500 is gone because .NET 11 registers an automatic CSRF middleware in
WebApplication.CreateBuilder, and it satisfies that requirement. What you can
now see is a different change: a cross-site browser form post to that endpoint
answers 400 (a request with no Sec-Fetch-Site and no Origin is allowed
through). Webhooks and partner forms that posted across sites need an explicit
exception, and the dotnet new blazor template no longer writes
app.UseAntiforgery(). Both are explained, with a lab, in
csrf-protection-antiforgery-tokens-and-fetch-metadata. If you keep
UseAntiforgery(), token behavior is unchanged.
Removals and stricter protocol handling (from the docs)
I did not run these on the GA. The migration guide and the .NET 11 breaking changes list them, so treat each as "search your code for it":
- Removed APIs: the
ConcurrencyLimitermiddleware (the rate limiter has a concurrency limiter),SetCompatibilityVersionandCompatibilityVersion,Router.PreferExactMatches, andAddDataAnnotationsValidation. - Kestrel is stricter on protocol compliance (for example headers forbidden in
HTTP/2 and HTTP/3, and a request that sends both
Content-LengthandTransfer-Encodingon HTTP/1.1). A proxy or client that relied on leniency can start failing. - Blazor enhanced navigation no longer preloads resources.
A routine that works
- Branch, retarget, pin the SDK, restore. Fix
NU1605and package bounds. - Build, then search the code for the removed APIs above.
- Run the whole test suite, not only the unit tests: the changes in this concept live in integration behavior (hosting, DI disposal, configuration, forms).
- Start the app in a staging copy and read the first minute of logs and the exit code of a forced failure.
- Regenerate OpenAPI documents and diff them against the committed ones.
Trade-offs
- Silent changes beat compiler errors. Every item with a table above compiled and ran. Only a test or a log line shows the difference, so an upgrade without integration tests is a guess.
- Some fixes are your code, some are a switch. The
Ignorebehavior for hosted services and theOpenApi3_1pin restore the old result but keep the old behavior you may want gone. Prefer fixing the cause (catch in the service, update the consumer of the document) and use the switch to buy time. - The LTS stays on 10. .NET 10 is the LTS, and 11 is the newer release. The
upgrade earns its cost when you need something from the release notes, not
because it is newer. See
dotnet-support-lifecycle-and-choosing-a-target. - Third parties lag. Document generators, API versioning and client generators release after the framework. Check each one against the new OpenAPI version and framework before the cutover.
- My results are one app each. The tables come from small programs, one per change. Your code may hit a variant (a different provider order, a different disposal path), so use them as the thing to look for, not as a proof you are safe.