TFMs and the .NET support lifecycle: what net8.0/net9.0/net10.0 and OS-specific TFMs mean, LTS versus STS, choosing targets for apps versus libraries, multi-targeting with TargetFrameworks and conditional code, and .NET 11 awareness.
A target framework moniker, or TFM, such as net10.0, tells the SDK which API surface to compile against and which runtime the app needs. OS-specific TFMs like net10.0-windows add platform APIs such as WPF. For applications I target one framework, normally the newest LTS: that's net10.0 today, supported until November 2028. .NET 8 and .NET 9 both reach end of support on 10 November 2026, so they need upgrading now. Libraries can multi-target with <TargetFrameworks>net8.0;net10.0</TargetFrameworks> while they still have consumers on older versions. That builds the library once per framework, and #if NET10_0_OR_GREATER handles API differences. .NET 11 is a preview, so it's for experiments only.
"A target framework moniker, like
net10.0, names the API surface a project compiles against and the runtime it needs. It's separate from the SDK version:global.jsonpicks the SDK, the TFM picks what the output runs on. OS-specific TFMs such asnet10.0-windowsadd platform APIs like WPF and WinForms, andnet10.0-androidornet10.0-iosare for mobile; that's a different thing from a runtime identifier, which names the OS and CPU you publish for.Choosing is lifecycle management. .NET 10 is LTS with three years of support, STS releases now get 24 months, and .NET 8 and .NET 9 both reach end of support on 10 November 2026, so anything still on them needs upgrading now. For applications I target one framework, normally the newest LTS. Libraries can multi-target with
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>while consumers are still moving; that builds once per target, the package gets alibfolder for each, and#if NET10_0_OR_GREATERhandles API differences.
netstandard2.0still has real uses: code that must run on .NET Framework or Unity, and Roslyn analysers and source generators, which must target it. The mistake I see is multi-targeting applications, which only doubles build and test time for a runtime you'll never deploy. .NET 11 previews belong on a branch, not in production. I keep the TFM inDirectory.Build.props, so an upgrade is one line plus fixes."
<TargetFramework>net10.0</TargetFramework> tells the SDK to compile against the .NET 10 reference assemblies and to write a runtimeconfig.json that asks for the .NET 10 runtime. Common forms:
| TFM | Meaning |
|---|---|
net10.0 |
.NET 10, cross-platform APIs |
net10.0-windows |
Adds Windows-only APIs (needed for WPF/WinForms with UseWPF/UseWindowsForms) |
net10.0-android, net10.0-ios |
.NET MAUI and mobile workloads |
netstandard2.0 |
A contract implemented by .NET Framework 4.6.1+, all modern .NET, Mono, Xamarin and Unity |
net48 |
.NET Framework 4.8 |
| Version | Type | End of support |
|---|---|---|
| .NET 8 | LTS | 10 November 2026 |
| .NET 9 | STS (extended to 24 months) | 10 November 2026 |
| .NET 10 | LTS | November 2028 |
| .NET 11 | STS, in preview | Awareness only until it ships |
LTS releases get three years of patches. In September 2025 Microsoft extended STS support from 18 to 24 months, starting with .NET 9, which is why .NET 8 and .NET 9 end on the same day. After that date there are no security patches, which matters for anything handling personal information under the Privacy Act or aligned with the Essential Eight's patching controls.
net10.0). Upgrade on a schedule, not when support has already ended.net8.0;net10.0, then drop net8.0 once consumers have moved.netstandard2.0 still has real uses: libraries that must also run on .NET Framework, libraries for Unity or other Mono-based runtimes, and Roslyn analysers and source generators, which must target netstandard2.0 because they load inside compilers and IDEs running on different runtimes. Accept its smaller API surface only when you need that reach.<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
Note the plural element. The SDK builds the project once per TFM and the NuGet package gets one lib/<tfm>/ folder each. For differences:
NET10_0, NET10_0_OR_GREATER, NET8_0_OR_GREATER, NETSTANDARD2_0.<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">.Multi-targeting multiplies build and test time (tests should run against every target), so only do it when someone needs it.
A shared Contoso.Telemetry library targeted netstandard2.0 long after every consumer had moved to modern .NET, so it couldn't use newer APIs and its consumers carried compatibility packages. Retargeting to net8.0;net10.0 removed three dependencies and let it use System.Threading.Lock and newer System.Text.Json features on .NET 10, with #if NET10_0_OR_GREATER keeping the .NET 8 build working for two teams that were still migrating.
Checks understanding of TFMs and lifecycle, not just syntax.
Green flags: explains what the TFM controls; knows LTS/STS and current end-of-support dates; distinguishes app and library targeting; uses TargetFrameworks and #if NETx_0_OR_GREATER correctly.
Red flags: multi-targets everything; doesn't know .NET 8 support is ending; thinks netstandard is the modern default.
<TargetFramework>net10.0</TargetFramework>
|
+--> reference assemblies Microsoft.NETCore.App.Ref 10.0 (the API surface you compile against)
+--> preprocessor symbols NET, NET10_0, NET10_0_OR_GREATER, NET9_0_OR_GREATER, NET8_0_OR_GREATER ...
+--> NuGet asset selection lib/net10.0, else the nearest compatible folder
+--> runtimeconfig.json "framework": { "name": "Microsoft.NETCore.App", "version": "10.0.0" }
+--> default C# version C# 14 for net10.0, C# 13 for net9.0, C# 12 for net8.0
+--> analyser defaults AnalysisLevel follows the TFM, so newer targets get newer rules
The TFM is about the API surface and runtime, not the SDK. The .NET 10 SDK builds net8.0, net9.0 and net10.0 projects; global.json chooses the SDK, the TFM chooses what the output runs on.
| TFM | Parts | Meaning |
|---|---|---|
net10.0 |
framework + version | .NET 10, cross-platform APIs only |
net10.0-windows |
+ OS | Adds Windows APIs; needed for WPF and WinForms (UseWPF, UseWindowsForms) |
net10.0-windows10.0.19041.0 |
+ OS version | Adds Windows Runtime APIs available from that Windows build |
net10.0-android, net10.0-ios, net10.0-maccatalyst |
+ OS | .NET MAUI and mobile workloads |
net10.0-browser |
+ OS | Browser WebAssembly |
netstandard2.0 |
contract | An API contract implemented by .NET Framework 4.6.1+, every modern .NET, Mono, Xamarin and Unity |
netstandard2.1 |
contract | Larger contract, not implemented by .NET Framework |
net48, net472 |
.NET Framework | Note: no dot, and different from net4.8 style names you sometimes see written by hand |
For OS TFMs, SupportedOSPlatformVersion sets the minimum OS version you support (it can be lower than the version in the TFM, which only sets which APIs you compile against).
| Version | Type | Released | End of support |
|---|---|---|---|
| .NET 8 | LTS | November 2023 | 10 November 2026 |
| .NET 9 | STS (24 months) | November 2024 | 10 November 2026 |
| .NET 10 | LTS | November 2025 | November 2028 |
| .NET 11 | STS | Due November 2026 (release candidates now) | About November 2028 |
| .NET 12 | LTS (expected) | Expected November 2027 |
Rules of thumb:
Is it an application or service you deploy?
yes --> one TFM: the newest LTS (net10.0). Plan the next LTS upgrade early.
no --> it's a library. Who consumes it?
only your own apps on .NET 10 --> net10.0
apps still moving from .NET 8 --> net8.0;net10.0 (drop net8.0 soon)
.NET Framework consumers too --> netstandard2.0 (+ net10.0 for newer APIs)
Roslyn analyser / source generator --> netstandard2.0 (required)
Unity or other Mono runtimes --> netstandard2.0 or netstandard2.1
A package can contain several lib/<tfm>/ folders. NuGet picks the nearest compatible one for the consuming project:
| Consumer | Package has | NuGet uses |
|---|---|---|
net10.0 |
net8.0, net10.0 |
net10.0 |
net9.0 |
net8.0, net10.0 |
net8.0 (nearest lower version) |
net10.0 |
netstandard2.0 only |
netstandard2.0 |
net48 |
netstandard2.0, net8.0 |
netstandard2.0 |
net48 |
net8.0 only |
Error NU1202: not compatible |
net10.0-windows |
net10.0, net10.0-windows |
net10.0-windows |
This is why a library that multi-targets net8.0;net10.0 still serves a net9.0 consumer: it gets the net8.0 build.
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
|
outer build ---+--> inner build (TargetFramework=net8.0) --> bin/Release/net8.0/
(dispatches) +--> inner build (TargetFramework=net10.0) --> bin/Release/net10.0/
|
dotnet pack --> one .nupkg with lib/net8.0/ and lib/net10.0/
dotnet test --> runs the tests once per target framework
dotnet build -f net10.0 builds one target only, which is handy locally.$(TargetFramework).$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0')) over string comparison when you mean "this target or newer".| Technique | Use when | Example |
|---|---|---|
#if symbols |
Small code differences | #if NET10_0_OR_GREATER ... #else ... #endif |
| Conditional items | A dependency only one target needs | <ItemGroup Condition="'$(TargetFramework)' == 'net8.0'"> |
| Separate files | Larger differences | Clock.net8.cs and Clock.net10.cs, each included conditionally |
| Polyfill packages | Missing attributes or small APIs on older targets | Packages that backfill required, init or IsExternalInit for older targets |
| Runtime checks | OS differences inside one TFM | if (OperatingSystem.IsWindows()), with [SupportedOSPlatform("windows")] on the API |
The common symbols: NET, NETCOREAPP, NETFRAMEWORK, NETSTANDARD, versioned forms such as NET10_0 and NET8_0_OR_GREATER, and OS symbols such as WINDOWS, ANDROID and IOS for OS-specific TFMs.
A framework-dependent net8.0 app asks for the .NET 8 runtime. By default it runs on the newest installed 8.0.x patch; it does not move to .NET 10 on its own.
RollForward value |
Effect |
|---|---|
Minor (default) |
Newest patch of the requested major; a higher minor if the requested one is missing |
LatestPatch |
Only patches of the exact major.minor |
Major |
A higher major version if the requested one isn't installed |
LatestMajor |
Always the highest installed version |
Disable |
Exactly the requested version |
Major lets an old app start on a newer runtime, but it hasn't been tested there. When a version reaches end of support, the fix is to retarget and test, not to rely on roll forward.
Directory.Build.props (for example <TargetFramework>net10.0</TargetFramework>) so the change is one line for the whole repo.global.json.Microsoft.Extensions.*, Microsoft.AspNetCore.*, EF Core) to the matching major version.<EnablePackageValidation>true</EnablePackageValidation> so dotnet pack checks that every target exposes a compatible API, and set PackageValidationBaselineVersion to catch breaking changes against the last release..NET 11 (net11.0) is due in November 2026 as an STS release with 24 months of support. Try it in a branch with its own global.json, or add net11.0 to a library's TargetFrameworks in a preview build only. Keep production applications on net10.0 (LTS) until .NET 11 has shipped and you have tested it, and remember that many organisations prefer to move from LTS to LTS (.NET 10 to .NET 12).
<TargetFrameworks>net8.0;net10.0</TargetFrameworks> (plural).#if NET10_0_OR_GREATER, build, and look in bin/Debug/ for two folders.dotnet pack and open the .nupkg (it's a zip) to see lib/net8.0 and lib/net10.0.dotnet build -f net8.0 to build only one target.net9.0 console app that references the library and check which folder it uses in obj/project.assets.json.<TargetFramework> to net8.0;net10.0 and read the error.bin/Debug/net10.0/*.runtimeconfig.json and read the framework version and roll-forward setting.<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<!-- Only the net8.0 build needs this polyfill package -->
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Contoso.Polyfills" Version="1.2.0" />
</ItemGroup>
</Project>
namespace Contoso.Telemetry;
public sealed class Counter
{
#if NET10_0_OR_GREATER
private readonly Lock _gate = new();
#else
private readonly object _gate = new();
#endif
private long _value;
public long Increment()
{
lock (_gate)
{
return ++_value;
}
}
}
System.Threading.Lock arrived in .NET 9, so NET9_0_OR_GREATER would also work; the example uses the .NET 10 symbol because those are the two targets.
Mistake: writing several targets in the singular <TargetFramework> element.
<TargetFramework>net8.0;net10.0</TargetFramework> is treated as one invalid TFM and the build fails.
Instead: use the plural <TargetFrameworks>net8.0;net10.0</TargetFrameworks> for multiple targets, and the singular form for one.
Mistake: staying on a TFM after its end of support.
.NET 8 and .NET 9 both stop receiving security patches on 10 November 2026. After that, every new vulnerability stays open, which is an audit finding for systems that handle personal information.
Instead: move applications to net10.0 (LTS) now, starting with internet-facing services, and plan each upgrade well before the end-of-support date.
Mistake: multi-targeting applications.
An app runs on one runtime in production, so extra targets only double build and test time and add #if noise.
Instead: give applications one TFM, and multi-target only libraries that have consumers on different versions.
Mistake: keeping netstandard2.0 out of habit.
It hides newer APIs and performance improvements, and consumers often need extra compatibility packages.
Instead: use netstandard2.0 only when you need it (.NET Framework or Unity consumers, Roslyn analysers and source generators), and otherwise target modern TFMs such as net8.0;net10.0 or just net10.0.
Mistake: targeting a preview (.NET 11) in production code.
Previews and release candidates can change APIs and behaviour, and have limited support.
Instead: try net11.0 in a branch with its own global.json, and keep production on net10.0 until .NET 11 has shipped and you have tested the upgrade.
Mistake: raising LangVersion above the default for an older TFM.
Some C# features need runtime or library support, so code compiles but fails or behaves differently at run time on the older target. It is also unsupported.
Instead: leave LangVersion unset so it follows each target framework, and use polyfill packages only for features that are known to work.
Mistake: expecting a net8.0 app to move to the .NET 10 runtime by itself.
Framework-dependent apps roll forward only to the newest patch of their own major version by default. On a server with only .NET 10 installed, a net8.0 app won't start at all.
Instead: retarget to net10.0 and test. Use RollForward set to Major only as a short, deliberate stopgap.
Mistake: confusing the SDK version with the target framework.
People assume a net8.0 project needs the .NET 8 SDK, or that installing the .NET 10 SDK upgrades the app.
Instead: pin the SDK in global.json and the target in <TargetFramework>. The .NET 10 SDK builds net8.0 projects, but the output still needs the .NET 8 runtime.
Mistake: testing only the newest target of a multi-targeted library.
Code inside #if branches for older targets is never run, so bugs reach the consumers who are still on them.
Instead: let dotnet test run every target framework in CI (it does by default when the test project multi-targets too).
Mistake: comparing TFMs with string conditions.
Condition="'$(TargetFramework)' == 'net10.0'" stops matching when you add net10.0-windows or move to net11.0.
Instead: use $([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0')) when you mean "this version or newer", and exact string matches only when you mean exactly one target.
Mistake: dropping a target framework from a library in a minor release. Consumers on that framework suddenly can't update, and NuGet reports NU1202 after what looked like a safe upgrade. Instead: treat removing a TFM as a breaking change: announce it, and release it in a new major version.
TFM choice is lifecycle management. A senior engineer keeps a simple policy: applications on the current LTS, an upgrade planned well before end of support, libraries targeting exactly what their consumers need and no more. With .NET 8 and .NET 9 both ending on 10 November 2026, that policy is urgent right now: an estate still on those versions needs a prioritised upgrade plan, starting with internet-facing services and anything processing personal information. They centralise the TFM in Directory.Build.props so an upgrade is one line plus fixes, and they track .NET 11 previews on a branch only, to learn what's coming without exposing production.
A runtime identifier, or RID, names the platform you build for, as OS plus architecture: win-x64, linux-x64, linux-musl-x64 for Alpine, linux-arm64 or osx-arm64. A normal framework-dependent app doesn't need one. It stays portable, and packages with native code ship runtimes/<rid>/ folders that the runtime picks from at start-up. You set a RID when the output must be platform-specific: self-contained, single-file, ReadyToRun or Native AOT. Since .NET 8 the SDK only understands portable RIDs, so distro-specific ones like ubuntu.22.04-x64 no longer work. Also since .NET 8, -r alone doesn't make the app self-contained.
The dotnet host looks for a global.json from the current folder upwards. If there isn't one, it uses the latest installed SDK, even a preview. If there is one, it reads sdk.version and applies the rollForward policy to choose from the installed SDKs. With a version and no policy, the default is latestPatch, meaning the same feature band with the newest patch. allowPrerelease controls whether previews count. If nothing matches, every SDK command fails. I pin the SDK in every repo so developers, IDEs and CI build with the same compiler, analysers and defaults. dotnet --info shows which SDK was picked and why.
dotnet pack builds the project and produces a .nupkg whose metadata comes from MSBuild properties in the project: PackageId, Version, Authors, Description, a licence expression, a repository URL and a README. I set the version from CI using Semantic Versioning, so a breaking change means a new major version. I publish symbols as a .snupkg with Source Link, so consumers can step into the exact source. I turn on package validation to catch accidental breaking changes. Then dotnet nuget push uploads to nuget.org or an internal feed, and organisations that need it can sign packages with dotnet nuget sign.
Last updated 1 Nov 2026.