Target Frameworks and Runtime Identifiers
|
This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its 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 |
|
One framework for all platforms; |
OS-specific .NET |
|
Adds platform APIs; |
.NET Standard |
|
A specification, not a runtime: an API set that several implementations share. |
.NET Framework |
|
Windows only. Note the spelling: no dot ( |
.NET Core (older) |
|
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"
}
}
Source: .NET: Target frameworks, Latest versions, Supported target frameworks, OS-specific TFMs, How to specify a target framework.
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.
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.
Source: .NET Standard, NuGet: Target frameworks.
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 asDirectory.Build.targets). InDirectory.Build.propsthe 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"orAfterTargets="Build"runs once per inner build (three times for Ledgerline) — and, if it is also reached from the outer build, once more. UseCondition="'$(TargetFramework)' != ''"or hook a target that only inner builds run. -
dotnet run,dotnet testanddotnet publishneed-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
ItemGroupelements, as Ledgerline does for itsnetstandard2.0polyfills.
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.
Source: .NET: Preprocessor symbols.
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.
Source: .NET: Deprecated target frameworks.
The support policy
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 |
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); -
RuntimeIdentifierin the project for apps that are always platform-specific; -
RuntimeIdentifiers(plural) to restore for several RIDs at once, so that later-rbuilds 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
Source: .NET RID catalog: Using RIDs.
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 |
|
Build for a RID |
|
Restore for several RIDs |
|
Common mistakes
-
net8ornet8.0.0instead ofnet8.0, ornet4.8instead ofnet48— the TFM spelling is exact. -
A condition on
$(TargetFramework)inDirectory.Build.props— works in multi-target inner builds, silently false in single-target projects. -
#if NET8_0instead 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) —NETSDK1083on .NET 8 and later. -
TargetFrameworkswith a single value — works, but turns on the outer/inner build machinery for no gain; useTargetFramework.
Release notes
-
.NET 5: one
netX.Yfamily replacesnetcoreappand the_OR_GREATERsymbols appear. -
.NET 8: the portable RID graph becomes the default (
NETSDK1206,UseRidGraph). -
.NET 9:
dotnet testruns 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).