Search nomadLab

.NET 8 and 9 End of Support: The .NET 10 Migration

Both versions stop getting patches on 10 November 2026, so staying on the LTS bought you nothing. What actually breaks is mostly .NET 9's doing, and the obsolete-API warnings you have been scrolling past are still warnings.

Updated

.NET 8 and .NET 9 reach end of support on the same day: 10 November 2026, about eleven weeks from now. If you deliberately stayed on .NET 8 because it was the Long Term Support release, you get exactly as much runway as the teams who chased .NET 9.

The reason they land together is a policy detail. LTS releases get three years from GA; Standard Term Support releases get two. .NET 8 shipped 14 November 2023, .NET 9 shipped 12 November 2024, and three years and two years both run out in November 2026. Microsoft’s support policy page states it plainly: STS is “supported for one year after a subsequent release,” and since releases land every twelve months, that adds up to two years.

Support windows for .NET 8, 9, and 10. .NET 8 released November 2023 with three years of LTS support and .NET 9 released November 2024 with two years of STS support; both end on 10 November 2026. .NET 10 released November 2025 and is supported until 14 November 2028. A three-year window and a two-year window end on the same day Nov 2023 Nov 2026 Nov 2028 .NET 8 · LTS · 36 months .NET 9 · STS · 24 months .NET 10 · LTS · to Nov 2028 10 Nov 2026 both stop here today
Picking the LTS release in 2023 bought thirty-six months. Picking the STS release a year later bought twenty-four. They expire together.

After 10 November there are no servicing updates and no security fixes. The runtime keeps running, obviously. You just carry whatever CVEs surface next with no patch coming.

Go to .NET 10, and skip 9 on the way

.NET 10 released 11 November 2025, is LTS, and is supported through 14 November 2028. It is the only sensible target. A .NET 11 has not appeared on the support policy page as of 22 August 2026; even when it does, an STS release gives you a shorter window and forces another migration in two years.

You do not step through 9 on the way to 10. Retarget straight from net8.0 to net10.0. The catch is that you inherit the breaking changes from both releases in one jump, and most of the interesting ones are .NET 9’s.

What .NET 9 does to you on the way past

These are the changes that bite an 8-to-10 jump, and they are easy to miss because people read the .NET 10 page and stop.

BinaryFormatter always throws. Not obsolete, not warned: it throws. If anything in your tree still round-trips objects through it, including a cache layer or a legacy remoting shim you inherited, that code path is dead on arrival and no compiler warning tells you.

Server GC now runs DATAS by default. Dynamic adaptation to application sizes was opt-in in .NET 8 and became the default in 9. Heap size now tracks long-lived data size rather than core count. For most services this is an improvement. For a service with hand-tuned GC settings and an SLA on tail latency, it is a behavior change you want to measure, not assume.

Exception handling was reimplemented on the NativeAOT model. Microsoft measures it at two to four times faster, and support for Windows structured exception handling is gone. If something breaks, System.Runtime.LegacyExceptionHandling in runtimeconfig.json puts the old implementation back, which is a useful thing to know before you spend a day bisecting.

Control-flow Enforcement Technology is on by default for apps on Windows. Hardware-enforced shadow stacks against return-oriented programming, categorized by Microsoft as binary incompatible, with a small performance cost. Native interop that does unusual things with the stack is the code to test here.

HttpClientFactory switched its primary handler to SocketsHttpHandler, and both header values and URI query strings are now redacted in its logs by default. If a runbook depends on reading a header out of a log line, that line is now *.

What .NET 10 does

Smaller list, and the first item is the one most write-ups get wrong.

Obsoletions in .NET 10 are warnings, not removals. The obsolete API list is five entries long: SYSLIB0058 through SYSLIB0062, covering SslStream cipher properties, SystemEvents.EventsThreadShutdown, the Rfc2898DeriveBytes constructors, two Queryable.MaxBy/MinBy overloads, and XsltSettings.EnableScript. They use custom diagnostic IDs, which means blanket-suppressing CS0618 will not silence them. Turning on TreatWarningsAsErrors under .NET 8 first is still good advice, but do it because it surfaces work early, not because the APIs are about to vanish.

System.Linq.AsyncEnumerable moved into the core libraries, and it collides with the community System.Linq.Async package. Microsoft’s guidance is to drop the package reference or move to Ix.NET v7. When the package arrives transitively through something else, add <ExcludeAssets>compile</ExcludeAssets> to the reference so your own code binds to the in-box type while dependencies keep using theirs. Most call sites survive untouched; the ones that do not are the Await variants, so e.SelectAwait(...) becomes e.Select(...).

OpenAPI generation is the ASP.NET Core change with the widest blast radius. WithOpenApi is deprecated, the MVC API analyzers and IncludeOpenAPIAnalyzers are deprecated, and Microsoft.Extensions.ApiDescription.Client is deprecated. If you generate a spec at build time and feed it into client codegen or an API gateway, diff the generated document, not just the API. Your tests exercise the endpoints; nothing exercises the description of them.

Also in ASP.NET Core 10: cookie login redirects are disabled for known API endpoints, so an unauthenticated call that used to get a 302 to a login page now gets a 401. That is the correct behavior and it is still a behavior change for any client that was following the redirect.

The SDK tightened several things into errors. A PackageReference without a version now raises an error, dotnet restore audits transitive packages, HTTP warnings became errors in dotnet package list and dotnet package search, and NuGet audit sources no longer allow insecure HTTP. Default container images also moved to Ubuntu, which matters if your Dockerfile installs OS packages by name.

The mechanical part

For a typical service the upgrade is short:

  1. Install the .NET 10 SDK, confirm dotnet --list-sdks, and update global.json if you pin.
  2. Change <TargetFramework>net8.0</TargetFramework> to net10.0 in every .csproj.
  3. Move all Microsoft.AspNetCore.*, Microsoft.EntityFrameworkCore.*, and Microsoft.Extensions.* packages to 10.x together. Mismatched versions inside the Microsoft.Extensions.* family produce runtime errors that read like something else entirely.
  4. Run dotnet list package --outdated and check every third-party dependency that ships a source generator, hooks ASP.NET Core internals, or does native interop.
  5. Build with warnings as errors, fix what surfaces.
  6. Run the tests, then look manually at auth flows end to end, at System.Text.Json output for polymorphic types and custom converters, and at p99 under load if you have a latency SLA.

Step 4 is where the calendar risk lives. Not in Microsoft’s code, in your dependency tree.

If November genuinely does not work

Third-party vendors sell backported security patches for end-of-life runtimes. That is a bridge, not a destination: it buys covered CVEs while you finish the migration, it costs money every month you defer, and at the end of the contract you still have to migrate. Price it against the engineering days honestly before you sign, because for a portfolio of services those two numbers get close fast.

Sizing a portfolio is triage rather than heroics. Group services by shared internal libraries, because they hit the same compatibility walls and fixing the library once unblocks the cluster. Prove the path on something that matters but is not gnarly, and let that first migration write the runbook. Then find the service with the abandoned dependency in week one rather than week ten, so you still have runway to fork it, replace it, or buy extended support for that one service specifically.

Run dotnet list package --outdated across your solutions today and flag anything that ships a source generator or has not published in a year. That list tells you whether November is a formality or a project.

If this is a recurring shape for your stack, the Python 3.10 end-of-life playbook walks the same triage for a different runtime, whose own wall lands ten days before this one.

Keep reading