Target Frameworks and Runtime Identifiers

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

Two identifiers decide what a .NET build produces. The target framework (TFM, such as net10.0) says which set of APIs the code compiles against and which runtime runs it; NuGet uses it to pick the right lib/<tfm>/ folder from every package. The runtime identifier (RID, such as linux-x64) says which operating system and processor architecture the output is for; it matters for native assets, self-contained deployments, apphosts and Native AOT. This page covers both with .NET SDK 10.0.402 and the Ledgerline library, which targets net10.0;net8.0;netstandard2.0. The introduction is in Namespaces, Assemblies and Projects — Target Frameworks and Multi-Targeting; #if and the other preprocessor directives are explained on Preprocessor Directives and Compilation.

Target framework monikers

Family TFMs Notes

.NET 5 and later

net5.0 … net8.0, net9.0, net10.0 (net11.0 with the .NET 11 preview SDK)

One framework for all platforms; net10.0 is the current LTS.

OS-specific .NET

net10.0-windows, net10.0-android, net10.0-ios, net10.0-maccatalyst, net10.0-macos, net10.0-tvos, net10.0-browser

Adds platform APIs; -windows also takes a version (net10.0-windows10.0.19041.0). Mobile TFMs need workloads.

.NET Standard

netstandard1.0 … netstandard2.0, netstandard2.1

A specification, not a runtime: an API set that several implementations share. netstandard2.0 is still the way to reach .NET Framework 4.6.1+ and .NET together.

.NET Framework

net462, net472, net48, net481

Windows only. Note the spelling: no dot (net48), unlike net8.0.

.NET Core (older)

netcoreapp3.1 and earlier

Out of support.

The project file sets the TFM with TargetFramework (one) or TargetFrameworks (several, separated by ;). The SDK translates the friendly string into the properties NuGet and the compiler really use. For each Ledgerline TFM:

cd src/Ledgerline
dotnet msbuild -p:TargetFramework=net8.0 -getProperty:TargetFrameworkMoniker -getProperty:TargetFrameworkIdentifier
dotnet msbuild -p:TargetFramework=netstandard2.0 -getProperty:TargetFrameworkMoniker -getProperty:TargetFrameworkIdentifier
{
  "Properties": {
    "TargetFrameworkMoniker": ".NETCoreApp,Version=v8.0",
    "TargetFrameworkIdentifier": ".NETCoreApp"
  }
}
{
  "Properties": {
    "TargetFrameworkMoniker": ".NETStandard,Version=v2.0",
    "TargetFrameworkIdentifier": ".NETStandard"
  }
}

Compatibility between frameworks

A project or package built for one TFM can be consumed by projects that target a compatible one. NuGet picks the nearest compatible lib/ folder; this is why Ledgerline ships a netstandard2.0 build (for .NET Framework 4.6.2+ and anything else that implements .NET Standard 2.0) next to the faster, API-richer net8.0 and net10.0 builds.

flowchart LR N10["net10.0"] --> N9["net9.0"] --> N8["net8.0"] --> N5["net5.0"] --> NS21["netstandard2.1"] --> NS20["netstandard2.0"] F48["net48 / net472 / net462"] --> NS20 NS20 --> NS1["netstandard1.x"] W["net10.0-windows"] --> N10 A["net10.0-android"] --> N10

An arrow means "can use assets built for". .NET Framework reaches .NET Standard 2.0 but not 2.1, which is the main reason library authors still target netstandard2.0.

Multi-targeting

With TargetFrameworks, MSBuild runs an outer build that dispatches one inner build per TFM, each with TargetFramework set as a global property. Each inner build has its own obj/<config>/<tfm>/ and bin/<config>/<tfm>/, its own references and its own compiler invocation:

dotnet build src/Ledgerline -tl:off -v:m
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Debug/netstandard2.0/Ledgerline.dll
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Debug/net8.0/Ledgerline.dll
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Debug/net10.0/Ledgerline.dll

In the outer build TargetFramework is empty, so querying the project without -p:TargetFramework shows only the list:

{
  "Properties": {
    "TargetFrameworks": "net10.0;net8.0;netstandard2.0",
    "TargetFramework": ""
  }
}

Consequences worth knowing:

  • Conditions on $(TargetFramework) are reliable only in the project file or in files imported after it (such as Directory.Build.targets). In Directory.Build.props the property has a value in inner builds, where it is a global property, but is empty in the outer build and in single-target projects.

  • A target hooked with BeforeTargets="Build" or AfterTargets="Build" runs once per inner build (three times for Ledgerline) — and, if it is also reached from the outer build, once more. Use Condition="'$(TargetFramework)' != ''" or hook a target that only inner builds run.

  • dotnet run, dotnet test and dotnet publish need -f <tfm> when a project has several TFMs (a test project runs each TFM in parallel since .NET 9).

  • Dependencies can differ per TFM with conditional ItemGroup elements, as Ledgerline does for its netstandard2.0 polyfills.

Preprocessor symbols

Each inner build defines symbols for its framework: the exact version (NET8_0), the family (NET, NETCOREAPP, NETSTANDARD, NETFRAMEWORK) and, since .NET 5, an _OR_GREATER symbol for every version up to it. They are added during execution by the AddImplicitDefineConstants target, so they are visible only after that target runs:

for f in net10.0 net8.0 netstandard2.0; do
  dotnet msbuild -p:TargetFramework=$f -t:AddImplicitDefineConstants -getProperty:DefineConstants
done
TRACE;DEBUG;NET;NET10_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NET9_0_OR_GREATER;NET10_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER
TRACE;DEBUG;NET;NET8_0;NETCOREAPP;NET5_0_OR_GREATER;NET6_0_OR_GREATER;NET7_0_OR_GREATER;NET8_0_OR_GREATER;NETCOREAPP1_0_OR_GREATER;NETCOREAPP1_1_OR_GREATER;NETCOREAPP2_0_OR_GREATER;NETCOREAPP2_1_OR_GREATER;NETCOREAPP2_2_OR_GREATER;NETCOREAPP3_0_OR_GREATER;NETCOREAPP3_1_OR_GREATER
TRACE;DEBUG;NETSTANDARD;NETSTANDARD2_0;NETSTANDARD1_0_OR_GREATER;NETSTANDARD1_1_OR_GREATER;NETSTANDARD1_2_OR_GREATER;NETSTANDARD1_3_OR_GREATER;NETSTANDARD1_4_OR_GREATER;NETSTANDARD1_5_OR_GREATER;NETSTANDARD1_6_OR_GREATER;NETSTANDARD2_0_OR_GREATER

Prefer the _OR_GREATER form, so that adding net11.0 later does not silently fall into an #else branch. A two-target console probe shows the branches compiled per TFM (the .NET 8 runtime was installed next to the SDK to run it):

#if NET10_0_OR_GREATER
const string Branch = "NET10_0_OR_GREATER";
#elif NET8_0_OR_GREATER
const string Branch = "NET8_0_OR_GREATER";
#else
const string Branch = "older";
#endif

Console.WriteLine($"{Branch} on {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");
dotnet run -f net10.0
dotnet run -f net8.0
NET10_0_OR_GREATER on .NET 10.0.13
NET8_0_OR_GREATER on .NET 8.0.32

Ledgerline itself uses the family symbol for its one polyfill: IsExternalInit.cs (needed by records and init on .NET Standard) is wrapped in #if !NET5_0_OR_GREATER.

TFM aliases and duplicate frameworks

The TargetFramework string is only an alias. NuGet and the compiler use TargetFrameworkMoniker (and TargetPlatformMoniker), which the SDK derives from it; a project can set those properties itself and use any alias. Since NuGet 7.6 / .NET SDK 10.0.300, several aliases may even resolve to the same framework, for example to build one assembly per platform:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFrameworks>net10.0;linux;osx</TargetFrameworks>
  </PropertyGroup>
  <PropertyGroup Condition="'$(TargetFramework)' == 'linux' or '$(TargetFramework)' == 'osx'">
    <TargetFrameworkIdentifier>.NETCoreApp</TargetFrameworkIdentifier>
    <TargetFrameworkVersion>v10.0</TargetFrameworkVersion>
    <TargetFrameworkMoniker>.NETCoreApp,Version=v10.0</TargetFrameworkMoniker>
    <DefineConstants>$(DefineConstants);LEDGERLINE_$(TargetFramework.ToUpperInvariant())</DefineConstants>
  </PropertyGroup>
</Project>
  Ledgerline.Native -> ~/demo/bin/Debug/linux/Ledgerline.Native.dll
  Ledgerline.Native -> ~/demo/bin/Debug/osx/Ledgerline.Native.dll
  Ledgerline.Native -> ~/demo/bin/Debug/net10.0/Ledgerline.Native.dll
Build succeeded.

Building works; packing does not until the project says which alias provides the package’s lib/net10.0 and its dependency group, because a package can hold only one per effective framework:

<sdk>/NuGet.Build.Tasks.Pack.targets(226,5): error NU5051: This project is using aliased frameworks, the following TargetFramework properties map to the same effective frameworks 'linux, net10.0, osx => net10.0' and pack is unable to disambiguate and create a single package. Ambiguous package dependencies. The following target frameworks are both declaring package dependencies for packing: linux, net10.0, osx [~/demo/Ledgerline.Native.csproj]

The restore-side checks are NU1018 (duplicate frameworks need SDK 10.0.300 or later and the default dependency resolver, not RestoreUseLegacyDependencyResolver) and NU1019 (an alias with a path separator or a non-ASCII character); the resolution for NU5051 is on Package Layout and Multi-Targeting.

Deprecated and out-of-support frameworks

Two different things are called "deprecated". Deprecated TFMs are names NuGet no longer recommends because the platform was renamed: aspnet50, dnxcore50, dnx45, dotnet5x, netcore50, win8, win81, wpa81, … with their replacements listed on the frameworks page. Out-of-support frameworks are real TFMs whose runtime no longer gets security fixes. The SDK warns about those, but only for projects that produce something runnable — the check (CheckEolTargetFramework) is off for class libraries:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0</TargetFramework>
  </PropertyGroup>
</Project>
<sdk>/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.EolTargetFrameworks.targets(32,5): warning NETSDK1138: The target framework 'net7.0' is out of support and will not receive security updates in the future. Please refer to https://aka.ms/dotnet-core-support for more information about the support policy. [~/demo/Old.csproj]

With Ledgerline's TreatWarningsAsErrors, this warning would fail an application build; libraries that keep an old TFM for their consumers are not affected.

The support policy

NET ships a major release every November. Even-numbered releases are LTS (at least three years of support), odd-numbered

ones STS (two years, 24 months). On 2026-10-23:

Release Type Latest SDK / runtime End of support

.NET 11

STS

11.0.100-rc.2 (preview, "go-live")

two years after its release (not yet dated in the release metadata)

.NET 10

LTS

10.0.402 / 10.0.13

2028-11-14

.NET 9

STS

9.0.319

2026-11-10

.NET 8

LTS

8.0.426

2026-11-10

NET 8 and .NET 9 end support on the same day. A library that still lists net8.0 keeps working on newer runtimes,

but after that date the net8.0 build gets no more servicing from the runtime side; Ledgerline keeps it for consumers that cannot upgrade immediately, and will drop it in its next major version (Designing a Library for NuGet).

Runtime identifiers

A RID has the form <os>[-<variant>]-<arch>: linux-x64, linux-arm64, linux-musl-x64 (Alpine), linux-bionic-arm64 (Android without the Android TFM), win-x64, win-arm64, osx-x64, osx-arm64, plus browser-wasm and others. A plain build is RID-agnostic ("portable"): it runs on any platform with the right runtime. A RID is needed when the output must be specific:

  • dotnet publish -r linux-x64 (self-contained, single-file, ReadyToRun, Native AOT, apphost for another OS);

  • RuntimeIdentifier in the project for apps that are always platform-specific;

  • RuntimeIdentifiers (plural) to restore for several RIDs at once, so that later -r builds can use --no-restore;

  • packages with native code under runtimes/<rid>/native/ (see Native Interop).

dotnet run                    # portable build, prints the RID the runtime detected
linux-x64

The portable RID graph since .NET 8

NuGet uses a RID graph to fall back when a package has no asset for the exact RID: linux-arm64 → linux → unix-arm64 → unix → any. Before .NET 8, the graph also contained version- and distribution-specific RIDs (ubuntu.22.04-x64, win10-x64, osx.13-arm64) and grew with every distro release. Since .NET 8 the SDK and runtime use only the portable graph, and such RIDs are no longer recognised:

dotnet build -r ubuntu.22.04-x64
<sdk>/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.FrameworkReferenceResolution.targets(190,5): error NETSDK1083: The specified RuntimeIdentifier 'ubuntu.22.04-x64' is not recognized. See https://aka.ms/netsdk1083 for more information. [~/demo/App.csproj]

When a package still contains assets for version-specific RIDs, the SDK reports NETSDK1206 ("Found version-specific or distribution-specific runtime identifier(s)") and names the packages. The fixes, in order: update the package; use a portable RID (win-x64 instead of win10-x64); or, as a last resort, set UseRidGraph to true to go back to the old, frozen graph.

A RID-specific build also changes the output path (bin/Debug/net10.0/linux-musl-x64/) and creates an apphost for that OS: dotnet run -r linux-musl-x64 on glibc-based Ubuntu builds fine but cannot start the musl apphost ("An error occurred trying to start process … No such file or directory").

Cross-tool equivalents

Task How

Build one TFM of a multi-targeted project

dotnet build -f net8.0 = msbuild -p:TargetFramework=net8.0; in Visual Studio, the framework drop-down next to the start button

Build for a RID

dotnet build -r linux-x64 = msbuild -restore -p:RuntimeIdentifier=linux-x64 (the restore must know the RID too)

Restore for several RIDs

<RuntimeIdentifiers>linux-x64;win-x64</RuntimeIdentifiers> in the project; nuget.exe and packages.config have no RID concept

Common mistakes

  • net8 or net8.0.0 instead of net8.0, or net4.8 instead of net48 — the TFM spelling is exact.

  • A condition on $(TargetFramework) in Directory.Build.props — works in multi-target inner builds, silently false in single-target projects.

  • #if NET8_0 instead of #if NET8_0_OR_GREATER — the code silently drops out when a newer TFM is added.

  • A library that targets only the newest TFM — consumers on the previous LTS cannot use it; multi-target instead.

  • Version-specific RIDs (win10-x64) — NETSDK1083 on .NET 8 and later.

  • TargetFrameworks with a single value — works, but turns on the outer/inner build machinery for no gain; use TargetFramework.

Release notes

  • .NET 5: one netX.Y family replaces netcoreapp and the _OR_GREATER symbols appear.

  • .NET 8: the portable RID graph becomes the default (NETSDK1206, UseRidGraph).

  • .NET 9: dotnet test runs the TFMs of a test project in parallel; .NET 9 is the first STS release with a two-year support period (it ends together with .NET 8).

  • NuGet 7.6 / SDK 10.0.300: duplicate frameworks through aliases (NU1018, NU1019, NU5051).