On November 10, 2026, .NET 8 and .NET 9 reach end of support on the same day. .NET 8 is the long-term support (LTS) release from November 2023. .NET 9 is a standard-term release, which Microsoft now supports for 24 months, so it ends together with .NET 8. After that date neither gets security fixes.
The destination is .NET 10, the LTS release that shipped in November 2025 and is supported until November 2028. If you’re on .NET 8 there’s no reason to stop at .NET 9: go straight to 10. This is the checklist I follow for a typical ASP.NET Core service, from the project file to the container image.
1. Install the SDK and pin it
Install the .NET 10 SDK, then make the repository say which SDK it expects. A global.json at the root keeps every machine and every CI run on the same major version while still picking up patch releases:
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
With latestFeature, any installed 10.0 SDK at or above 10.0.100 is accepted, but a newer major version is not.
2. Change the target framework in one place
If your solution has a Directory.Build.props, the upgrade is a one-line change for every project. If it doesn’t, this is a good moment to add one:
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Projects that target net10.0 compile as C# 14 by default, so there’s no LangVersion to bump.
3. Update the packages that follow the runtime
Packages such as Microsoft.EntityFrameworkCore.*, Microsoft.Extensions.* and Microsoft.AspNetCore.* are versioned with .NET, so the 8.x versions should move to 10.x together. The SDK now has noun-first commands, and this one lists what’s behind:
dotnet package list --outdated
Two things tend to surprise people at this step:
- Restore now audits transitive packages. For projects targeting .NET 10,
NuGetAuditModedefaults toall, so a vulnerable package deep in your dependency tree produces anNU1901–NU1904warning. WithTreatWarningsAsErrors, that fails the restore. Rundotnet nuget why <project> <package>to see which top-level package brings it in, and upgrade that one. - LINQ for
IAsyncEnumerableis now built in. .NET 10 shipsSystem.Linq.AsyncEnumerable, which clashes with the communitySystem.Linq.Asyncpackage. Remove that package reference (or move to its version 7), and replace calls such asSelectAwaitwith the newSelectoverloads.
4. Test the behaviour changes, not just the build
A clean build isn’t the finish line. Going from 8 to 10 means taking the .NET 9 and .NET 10 breaking changes together. These are the ones most likely to affect a web API or a worker service:
BackgroundServicestarts differently. In .NET 10, all ofExecuteAsyncruns on a background thread. Before, the code up to the firstawaitran synchronously during startup and blocked other services. If you relied on that ordering, move the work to the constructor or overrideStartAsync.nullin configuration staysnull. The binder used to treat anullvalue as missing, and the JSON provider turned it into an empty string. Now"Timeout": nullbinds asnull, so check options classes that have non-null defaults.- Tracing uses the W3C propagator by default.
DistributedContextPropagator.Currentnow follows the W3C Trace Context and Baggage specifications. Baggage no longer uses the oldCorrelation-Contextheader. If an older service downstream still expects it, you can switch back withDistributedContextPropagator.CreatePreW3CPropagator()while you migrate. HttpClientFactorylogs redact header values (a .NET 9 change). If you debug integrations from logs, you’ll see*where header values used to be. CallRedactLoggedHeaderswith only the sensitive headers to log the others again.BinaryFormatteralways throws (also .NET 9). If anything still serializes with it, this is the upgrade where it finally breaks.- The runtime no longer installs its own
SIGTERMhandler. ASP.NET Core and anything on the Generic Host are unaffected, because the host registers its own. A plain console app that counted onProcessExitto clean up needs aPosixSignalRegistration.
Advertisement
5. Move the container images and the pipeline
Update the image tags in your Dockerfile from 8.0 to 10.0:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/Api -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "Api.dll"]
The default .NET 10 images are based on Ubuntu, and Debian images are no longer provided. Most apps won’t notice. However, a RUN apt-get install … line that depends on a Debian package name is worth testing.
In CI, let the pipeline read the SDK version from global.json, so there’s only one place to change next time. With GitHub Actions:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json
- run: dotnet test
6. Adopt C# 14 where it removes code
Nothing forces you to use the new language features, but a few of them delete real boilerplate. The examples below compile and run on the .NET 10 SDK.
The field keyword refers to the compiler-generated backing field, so a property with a little logic in its setter no longer needs a private field:
public sealed class Customer
{
public required string Name { get; init; }
public required string Email
{
get;
set => field = value.Trim().ToLowerInvariant();
}
}
Null-conditional assignment allows ?. on the left of =. The right side is only evaluated when the target isn’t null:
customer?.Email = LoadEmail(); // LoadEmail() doesn't run when customer is null
Extension members add extension properties and static extensions, grouped in an extension block:
public static class StringExtensions
{
extension(string value)
{
public bool IsBlank => string.IsNullOrWhiteSpace(value);
public string Truncate(int maxLength) =>
value.Length <= maxLength ? value : value[..maxLength];
}
}
// "".IsBlank == true, "Hello, .NET 10".Truncate(5) == "Hello"
Also handy: nameof(List<>) now works with unbound generic types, and lambda parameters can take modifiers without their types, as in (text, out result) => int.TryParse(text, out result).
7. Bonus: scripts without a project
.NET 10 can run a single .cs file with dotnet run app.cs, with no .csproj. Add a shebang and it runs like any script:
#!/usr/bin/env dotnet
Console.WriteLine($"Running on .NET {Environment.Version}");
File-based apps can reference packages and projects with #:package and #:project directives, and dotnet publish app.cs produces a native executable. They’re a good replacement for the small console projects that pile up in a tools/ folder.
The short version
- Install the .NET 10 SDK and pin it in
global.json. - Change
TargetFrameworktonet10.0, ideally inDirectory.Build.props. - Update the
Microsoft.*packages to 10.x, and deal with NuGet audit warnings andSystem.Linq.Async. - Test the behaviour changes:
BackgroundServicestartup,nullconfiguration values, W3C tracing, redacted headers,BinaryFormatterandSIGTERM. - Move the Docker images (now Ubuntu-based) and point CI at
global.json. - Adopt C# 14 where it deletes code.
Start with the service with the best test coverage, and give yourself a few weeks before November 10.


