SDK resolution: how the dotnet host picks an SDK, what global.json's version, rollForward and allowPrerelease settings do, why pinning matters for IDEs and CI, and how to diagnose 'compatible SDK not found' failures.
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.
"The
dotnethost looks for aglobal.jsonfrom the current folder upwards and uses the first one it finds. With no file it takes the latest installed SDK, previews included. With one, it readssdk.versionand applies the rollForward policy, which defaults tolatestPatchwhen a version is set, andallowPrereleasedecides whether previews count; if nothing matches, every SDK command fails with a compatible-SDK-not-found error. The SDK is separate from the target framework: the .NET 10 SDK builds anet8.0project, but with a newer compiler, analysers and warnings.So I pin the SDK in every repo, usually with
latestFeaturerather thandisableso security patches still arrive, because Visual Studio, Rider, C# Dev Kit and the CI setup tasks all honour the file. When something looks wrong, I rundotnet --infofrom the failing folder anddotnet --list-sdks, and an SDK mismatch is often why the IDE and the CLI disagree.For example, at [company] the shared build agent image was refreshed overnight and [about 30] repos failed with no code changes. I needed builds green quickly and to stop agent changes breaking us again. Comparing
dotnet --infobetween runs, I found the agents had silently moved to a [.NET 11 preview] SDK because no repo pinned one, and its new analyser warnings were failingTreatWarningsAsErrors. I added aglobal.jsonwithlatestFeatureand prereleases off, switched the pipelines to install from that file withUseDotNet@2, and added a check to the shared template. Builds were green within [two hours], and later image updates caused [no] unplanned breakages.I treat the SDK as a dependency with its own upgrade pull request, and in a monorepo I keep a single
global.jsonat the root, because a nested one silently takes over."
global.json or "latest installed".TargetFramework (net10.0) and, at run time, runtimeconfig.json.An SDK can build older targets: the .NET 10 SDK builds net8.0 projects. But the SDK still changes the compiler version, analyser defaults and warnings, so an unpinned SDK upgrade can break a build that targets an old framework.
global.json.sdk.version, sdk.rollForward and sdk.allowPrerelease, then pick the best installed SDK that satisfies them.| Policy | Accepts (for version 10.0.100) |
|---|---|
patch |
10.0.1xx, preferring the requested patch |
latestPatch (default when version set) |
newest installed 10.0.1xx |
feature / latestFeature |
10.0.1xx first, then higher feature bands (10.0.2xx, ...) / the newest 10.0.x |
minor / latestMinor |
also higher minor versions |
major / latestMajor |
any newer SDK, including 11.0 |
disable |
exactly 10.0.100 only |
SDK versions are major.minor.feature-band+patch: in 10.0.105, 1 is the feature band and 05 the patch. Feature bands can change MSBuild and analyser behaviour, so most teams pin with latestFeature (stay on 10.0, take the newest band) or latestPatch (stay on the band, take security patches).
A build agent image was updated overnight and gained a .NET 11 preview SDK. Repos without global.json started building with the preview: new analyser warnings broke builds that used TreatWarningsAsErrors. Adding global.json with "version": "10.0.100", "rollForward": "latestFeature" and allowPrerelease: false fixed it, and actions/setup-dotnet with global-json-file made agents install exactly what the repo asked for.
global.json at the repo root of every application repo. Create it with dotnet new globaljson --sdk-version 10.0.100.rollForward: latestFeature or latestPatch, not disable, so security patches are picked up without editing the file.global.json rather than hard-coding a version in the pipeline.allowPrerelease: true, never on main.Checks whether the candidate understands SDK resolution and reproducible builds.
Green flags: separates SDK from runtime/TFM; knows the search order and rollForward; uses dotnet --info; pins in CI from the same file.
Red flags: "we just install the latest"; thinks global.json sets the target framework; uses disable everywhere.
When you type dotnet build, three separate components are involved:
| Component | What it is | Its job |
|---|---|---|
| Muxer (host) | The dotnet / dotnet.exe executable on your PATH |
Works out whether you asked to run an app (dotnet app.dll) or an SDK command (dotnet build) |
| hostfxr | A native library in dotnet/host/fxr/<version>/ |
Finds global.json, applies its rules and picks an installed SDK |
| SDK | A folder such as dotnet/sdk/10.0.105/ |
Contains the CLI commands, MSBuild, the Roslyn compiler, analysers, templates and NuGet |
Only one muxer runs: the first dotnet found on the PATH. hostfxr then looks for SDKs in the sdk folder next to that muxer, so a second installation elsewhere on the machine is invisible to it. From .NET 10, global.json can also name extra SDK locations (see section 6).
dotnet build
|
v
muxer: first dotnet on PATH (e.g. /usr/share/dotnet/dotnet)
|
v
hostfxr: walk up from the CURRENT DIRECTORY
./global.json ? ../global.json ? ... filesystem root
|
+-- none found --> newest SDK installed next to this muxer
| (previews included unless told otherwise)
|
+-- found --> read sdk.version, rollForward, allowPrerelease
filter the installed SDKs (dotnet --list-sdks)
|
+-- match --> load dotnet/sdk/<version>/dotnet.dll
| --> MSBuild, Roslyn, NuGet
|
+-- none --> "A compatible .NET SDK was not found"
(lists the requested version, the
global.json path and installed SDKs)
hostfxr searches from the current working directory, not from the project you pass on the command line. These two commands can use different SDKs:
cd ~/repos/orders && dotnet build # uses ~/repos/orders/global.json
cd ~ && dotnet build repos/orders/src/Orders.Api # starts searching in ~ and may not find it
It stops at the first global.json it finds. A file in a parent folder (for example one someone left in their home folder, or a monorepo root) applies to every repo below it unless a closer file overrides it.
An SDK version looks like 10.0.105:
10 . 0 . 1 05
| | | |
| | | +-- patch (05): security and bug fixes, released roughly monthly
| | +---- feature band (1 = 1xx): new MSBuild/Roslyn/CLI features
| +-------- minor (0)
+------------- major (10): matches the .NET release
net8.0 or net9.0.Assume "version": "10.0.100" and these SDKs installed: 10.0.103, 10.0.105, 10.0.201, 11.0.100-preview.3.
| Policy | Rule | Picks |
|---|---|---|
disable |
Exactly the requested version | fails (10.0.100 not installed) |
patch |
The requested version; if missing, the latest patch in the same band | 10.0.105 |
latestPatch |
The latest patch in the same band (default when version is set) |
10.0.105 |
feature |
The latest patch in the requested band; if none, the next higher band | 10.0.105 |
latestFeature |
The highest band and patch with the same major.minor | 10.0.201 |
minor / latestMinor |
Like feature, then allows higher minor versions | 10.0.105 / 10.0.201 |
major |
Like minor, then allows higher major versions if nothing else matches | 10.0.105 |
latestMajor |
The highest installed SDK at or above the requested version (default when no version is set) |
10.0.201 (or 11.0.100-preview.3 if prereleases are allowed) |
Two rules apply to every policy:
10.0.200 is requested and only 10.0.105 is installed, it fails under every policy.allowPrerelease allows them.allowPrerelease decides whether preview SDKs can be selected. If you don't set it, the default depends on where the command runs. From the command line it is true. Inside Visual Studio it follows Visual Studio's own preview setting. That difference is why a build can pick a preview SDK in a terminal or on a CI agent but not in the IDE. Set "allowPrerelease": false explicitly on main branches so the result is the same everywhere.
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature",
"allowPrerelease": false,
"paths": [ ".dotnet", "$host$" ],
"errorMessage": "Install the .NET 10 SDK: run ./eng/install-sdk.sh or see README.md."
},
"msbuild-sdks": {
"Microsoft.Build.Traversal": "4.1.0"
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
| Setting | Purpose |
|---|---|
sdk.version |
The minimum SDK to start from |
sdk.rollForward |
Which installed SDKs are acceptable (section 4) |
sdk.allowPrerelease |
Whether previews count (section 5) |
sdk.paths (new in .NET 10) |
Where to look for SDKs, in order. $host$ means the folder of the running dotnet. Useful for a repo-local SDK in .dotnet/ |
sdk.errorMessage (new in .NET 10) |
A custom message shown when no SDK matches, so new starters know what to install |
msbuild-sdks |
Pins versions of MSBuild project SDKs restored from NuGet, such as Microsoft.Build.Traversal |
test.runner (new in .NET 10) |
Chooses whether dotnet test uses VSTest or Microsoft.Testing.Platform |
Older SDKs ignore the settings they don't understand, so adding the .NET 10 fields is safe in a mixed environment.
These two are often confused:
| SDK selection | Runtime selection | |
|---|---|---|
| Decides | Which SDK builds the code | Which runtime runs the app |
| Configured in | global.json (rollForward) |
Project <RollForward> property, written to *.runtimeconfig.json, or the DOTNET_ROLL_FORWARD environment variable |
| Happens | When you run an SDK command | When the app starts |
DOTNET_ROLL_FORWARD=Major lets a net8.0 app run on the .NET 10 runtime. It has no effect on which SDK dotnet build uses.
| Tool | Behaviour |
|---|---|
dotnet CLI |
Always, via hostfxr |
| Visual Studio | Resolves the SDK with the same rules. Each Visual Studio version supports SDKs up to a certain band, so an older Visual Studio can fail to load a project that pins a newer SDK |
| Rider, VS Code C# Dev Kit | Use the SDK resolved for the solution folder |
GitHub Actions actions/setup-dotnet |
Installs the version from the file with global-json-file |
Azure Pipelines UseDotNet@2 |
Installs it with useGlobalJson: true |
Docker mcr.microsoft.com/dotnet/sdk:10.0 |
Contains one SDK. If global.json asks for a newer one, the build inside the container fails |
Optional workloads such as maui, wasm-tools and aspire (on older SDKs) are installed per SDK feature band. When latestFeature moves a machine from 10.0.1xx to 10.0.2xx, the new band has no workloads until you run dotnet workload restore or dotnet workload install. That is a common cause of "it built yesterday" on developer machines.
dotnet --info from the folder where the command fails. It shows the SDK in use, the global.json file: path it found, the host path and the installed SDKs and runtimes.dotnet --list-sdks and compare it with the policy in section 4.which dotnet (macOS/Linux) or where.exe dotnet (Windows) to check which muxer is first on the PATH.dotnet --info as the first step of every job, so a changed agent image is visible in the logs.dotnet sdk check to see which installed SDKs are out of support or have patches available.rollForward: disable stops security patches being picked up, so machines and agents keep running SDKs with known vulnerabilities. Prefer latestPatch or latestFeature and keep SDKs updated.sdk.paths gives full control, but then your team owns patching it.dotnet --list-sdks and note the versions.dotnet new globaljson --sdk-version 10.0.100 --roll-forward disable, then dotnet --version.10.0.999, and run dotnet build to see the error.rollForward to latestPatch, then latestFeature, and run dotnet --version after each change. Compare with the table in section 4.cd to the parent folder and run dotnet --info again: the global.json line disappears, because the search starts from the current directory.DOTNET_ROLL_FORWARD=Major and run dotnet --version. It doesn't change, because that variable controls the runtime, not the SDK.# Create a pinned global.json at the repo root
dotnet new globaljson --sdk-version 10.0.100 --roll-forward latestFeature
# What did the host choose, and why?
dotnet --info
dotnet --list-sdks
# GitHub Actions: install exactly what the repo asks for
- uses: actions/setup-dotnet@v4
with:
global-json-file: global.json
- run: dotnet --info
# Azure Pipelines equivalent
- task: UseDotNet@2
inputs:
useGlobalJson: true
- script: dotnet --info
Mistake: having no global.json at all.
Every machine then builds with the newest SDK it happens to have, including previews. Builds break when someone installs a new SDK or a CI agent image changes, even though no code changed.
Instead: commit a global.json at the repo root, for example with dotnet new globaljson --sdk-version 10.0.100 --roll-forward latestFeature, and set "allowPrerelease": false.
Mistake: pinning an exact patch with rollForward: disable.
Every monthly SDK patch breaks the build until someone edits the file, so teams stop installing patches and run SDKs with known vulnerabilities.
Instead: use latestPatch (stay on the band, take every patch) or latestFeature (stay on the major and minor, take the newest band). Use disable only for a deliberate, short-lived reason such as reproducing a bug in one exact SDK.
Mistake: believing global.json sets the target framework or the runtime.
It only chooses the SDK. Pinning SDK 10 doesn't make a net8.0 app run on .NET 10, and a net10.0 app still fails on a server that only has the .NET 8 runtime.
Instead: set the target with <TargetFramework> in the project file, and make sure the deployment target has the matching runtime, or publish self-contained.
Mistake: confusing SDK roll forward with runtime roll forward.
DOTNET_ROLL_FORWARD and the project's <RollForward> property control which runtime runs an app. They have no effect on which SDK dotnet build uses.
Instead: control the SDK with sdk.rollForward in global.json, and the runtime with <RollForward> or runtimeconfig.json.
Mistake: assuming a newer SDK changes nothing for older targets.
The .NET 10 SDK builds net8.0 projects, but with a newer compiler, newer analyser defaults and new warnings. With TreatWarningsAsErrors, an SDK upgrade alone can break the build.
Instead: upgrade the SDK in a dedicated pull request that bumps global.json, fixes the new warnings and is reviewed like any other change.
Mistake: running commands from a folder where global.json isn't found.
The search starts from the current directory, not the project path. A global.json in a sub-folder, or a build started from outside the repo, silently uses a different SDK.
Instead: keep one global.json at the repo root and run commands from inside the repo. If a build behaves unexpectedly, run dotnet --info from that folder and check the global.json file: line.
Mistake: hard-coding the SDK version in the pipeline as well as in global.json.
The two drift apart. The pipeline installs one version and the file asks for another, so the build either fails or uses the wrong SDK.
Instead: make the file the single source of truth: actions/setup-dotnet with global-json-file: global.json, or UseDotNet@2 with useGlobalJson: true.
Mistake: pinning an SDK that the build container doesn't have.
An mcr.microsoft.com/dotnet/sdk:10.0 image contains one SDK. If global.json asks for a newer band, docker build fails with "A compatible .NET SDK was not found".
Instead: keep the image and the file in step. Either pin the image to the exact SDK version tag (for example FROM mcr.microsoft.com/dotnet/sdk:10.0.201 AS build) and update it in the same pull request that bumps global.json, or use a moving tag such as sdk:10.0 with rollForward: latestFeature so any 10.0 band in the image is accepted. Add RUN dotnet --info to the build stage so a mismatch is obvious in the build log.
Mistake: having two dotnet installations on the PATH.
For example, a system install plus a user install from dotnet-install.sh. The terminal and the IDE can start different dotnet executables, and each only sees the SDKs next to itself.
Instead: use one installation per machine, and check with which dotnet or where.exe dotnet. If you need a repo-local SDK, declare it with sdk.paths in global.json (.NET 10).
Mistake: forgetting that workloads belong to a feature band.
When latestFeature moves a machine to a new band (10.0.1xx to 10.0.2xx), workloads such as MAUI or wasm-tools aren't installed for the new band, and the build fails.
Instead: run dotnet workload restore after an SDK band change, and include it in onboarding and CI setup scripts.
Mistake: relying on the default for allowPrerelease.
Unset, it is true from the command line but follows Visual Studio's preview setting inside Visual Studio. A CI agent with a preview installed can therefore pick it while the IDE doesn't.
Instead: set "allowPrerelease": false explicitly on main branches, and try previews only on a branch with its own global.json.
Pinning the SDK is the cheapest reproducibility win in .NET. A senior engineer treats the SDK as a dependency with an upgrade process: one pull request bumps global.json, fixes new warnings, and is reviewed like any other change. They choose a roll-forward policy that balances stability and security (latestFeature for most teams, latestPatch for very conservative ones), and they make CI install from the file so there is one source of truth. In a monorepo they keep a single global.json at the root; a nested one in a sub-folder silently takes over for anything built from there, which is a classic source of "works in the IDE, fails in CI".
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.
The IDE's errors come from design-time builds and the language service, not from the real compiler run, so a mismatch means the IDE has a different view of the project. I first confirm which side reports the error by filtering the Error List to Build only and IntelliSense only. Then I check the usual causes: the IDE resolved a different SDK from the CLI (fix with global.json), stale caches in obj/ or .vs/, a failed restore, a source generator that has not re-run, or the wrong target framework context in a multi-targeted project. I fix the root cause and confirm with a clean dotnet build.
I'd treat the build as a product, with a contract that lives in files at the repo root:
global.json for the SDK.Directory.Build.props/.targets for framework, nullable, analysers and output layout.Directory.Packages.props with transitive pinning for versions.nuget.config with source mapping, plus lock files..editorconfig for style and analyser severities.Project files stay tiny. A small platform group owns those files through CODEOWNERS, and there's a documented exception process for overrides and suppressions. CI uses shared templates that run exactly what developers run locally. The warning policy ratchets stricter over time. SDK, framework and package upgrades arrive as automated, grouped pull requests. I'd measure it all (build time, warnings, drift, time to patch) so governance helps teams instead of just blocking them.
How to tell it (STAR)
**Situation:** One morning [about 30] repos at [company] failed to build after the shared agent image was refreshed overnight, with no code changes. **Task:** I needed to get builds green quickly and stop agent changes from breaking us again. **Action:** Comparing `dotnet --info` between runs showed the agents had silently moved to a [.NET 11 preview] SDK because none of the repos pinned one. I added a global.json with `latestFeature` and no prereleases, switched pipelines to install from that file, and added a check to the shared template. **Result:** Builds were green within [two hours], and later agent image updates caused [no] unplanned breakages.
Last updated 1 Nov 2026.