📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does the .NET SDK decide which SDK version to use, and how does global.json change that?

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.

Mid-Level Debugging Intermediate ci-cd dotnet-sdk global-json project-system reproducible-builds

🎯 Key points

  • Without global.json, the dotnet host uses the latest SDK installed on the machine, including previews
  • The host searches for global.json from the current directory upwards and uses the first one it finds
  • sdk.version sets the requested SDK; rollForward decides which installed SDKs are acceptable (default latestPatch when a version is given)
  • allowPrerelease controls whether preview SDKs can be selected
  • If no installed SDK satisfies the policy, every SDK command fails with a 'compatible .NET SDK was not found' error
  • The SDK version is separate from the TFM: an SDK 10.0.1xx can build net8.0 projects, but a newer SDK brings a newer compiler, analysers and warnings, so builds can still change
  • Visual Studio, Rider, VS Code C# Dev Kit and CI setup tasks (actions/setup-dotnet, UseDotNet@2) honour global.json, so pinning keeps everyone on the same compiler and defaults
  • dotnet --info and dotnet --list-sdks show what was chosen and what is installed

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"The dotnet host looks for a global.json from 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 reads sdk.version and applies the rollForward policy, which defaults to latestPatch when a version is set, and allowPrerelease decides 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 a net8.0 project, but with a newer compiler, analysers and warnings.

So I pin the SDK in every repo, usually with latestFeature rather than disable so 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 run dotnet --info from the failing folder and dotnet --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 --info between 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 failing TreatWarningsAsErrors. I added a global.json with latestFeature and prereleases off, switched the pipelines to install from that file with UseDotNet@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.json at the root, because a nested one silently takes over."


Two separate decisions

  • Which SDK builds the code (the CLI, MSBuild, Roslyn compiler, analysers, templates). Chosen by global.json or "latest installed".
  • Which runtime the app targets and runs on. Chosen by the project's 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.

The resolution rules

  1. The host walks up from the current directory looking for global.json.
  2. No file: use the newest installed SDK (previews included).
  3. File found: read sdk.version, sdk.rollForward and sdk.allowPrerelease, then pick the best installed SDK that satisfies them.
  4. Nothing satisfies them: fail with "A compatible .NET SDK was not found" and list what is installed.

rollForward policies

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).

Production example

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.

Best practices, performance and security

  • Commit global.json at the repo root of every application repo. Create it with dotnet new globaljson --sdk-version 10.0.100.
  • Use rollForward: latestFeature or latestPatch, not disable, so security patches are picked up without editing the file.
  • Let CI install the SDK from global.json rather than hard-coding a version in the pipeline.
  • Upgrade the SDK in a deliberate pull request, so new warnings are fixed in one place.
  • Treat .NET 11 previews as awareness only: try them on a branch with allowPrerelease: true, never on main.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Three pieces: host, hostfxr and the SDK

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)

2. The search starts from the current directory

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.

3. Reading an SDK version number

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
  • Patches within a band are meant to be safe to take.
  • Feature bands (10.0.1xx, 10.0.2xx, 10.0.3xx) usually ship alongside Visual Studio minor releases. A new band can bring a newer MSBuild and compiler, new analyser rules and new warnings, so moving bands is a real change even though the major version stays the same.
  • The SDK version is independent of the runtime version: SDK 10.0.105 ships with a 10.0.x runtime, but it can build projects that target net8.0 or net9.0.

4. Every rollForward policy, with an example

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:

  • The resolver never rolls backwards. If 10.0.200 is requested and only 10.0.105 is installed, it fails under every policy.
  • Prerelease SDKs are only candidates when allowPrerelease allows them.

5. allowPrerelease and its default

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.

6. Everything global.json can contain (.NET 10)

{
  "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.

7. SDK roll forward is not runtime roll forward

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.

8. Who honours global.json

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

9. Workloads are tied to the feature band

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.

10. Diagnosing a resolution problem

  1. Run 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.
  2. Run dotnet --list-sdks and compare it with the policy in section 4.
  3. Run which dotnet (macOS/Linux) or where.exe dotnet (Windows) to check which muxer is first on the PATH.
  4. In CI, print dotnet --info as the first step of every job, so a changed agent image is visible in the logs.
  5. Run dotnet sdk check to see which installed SDKs are out of support or have patches available.

11. Performance and security

  • Resolution itself is fast. The cost of an unpinned SDK is in non-reproducible builds: a CI cache keyed on the SDK version misses after every agent image update.
  • 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.
  • A repo-local SDK through sdk.paths gives full control, but then your team owns patching it.

12. Try it yourself

  1. Run dotnet --list-sdks and note the versions.
  2. In an empty folder, run dotnet new globaljson --sdk-version 10.0.100 --roll-forward disable, then dotnet --version.
  3. Change the version to one you don't have, such as 10.0.999, and run dotnet build to see the error.
  4. Change rollForward to latestPatch, then latestFeature, and run dotnet --version after each change. Compare with the table in section 4.
  5. cd to the parent folder and run dotnet --info again: the global.json line disappears, because the search starts from the current directory.
  6. Set DOTNET_ROLL_FORWARD=Major and run dotnet --version. It doesn't change, because that variable controls the runtime, not the SDK.

💻 Code

# 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

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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".

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains SDK pinning; interviewer checks they know it doesn't set the TFM. Why: Separates SDK selection from the target framework.
    How to answer

    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.

  2. When: Candidate mentions IDEs honouring global.json. Why: SDK mismatches are a common cause of IDE-versus-CLI differences.
    How to answer

    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.

  3. When: Candidate talks about pinning the SDK across many repos or teams. Why: Places SDK pinning inside a wider repo governance design.
    How to answer

    I'd treat the build as a product, with a contract that lives in files at the repo root:

    • global.json for the SDK.
    • Layered 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.

🛠 Real-world problems: how you handle them

Preview SDK on a build agent breaks every unpinned repo
Situation
A platform team refreshed the shared Azure DevOps agent image. It now included a .NET 11 preview SDK alongside .NET 10. About 30 repos had no global.json and used TreatWarningsAsErrors.
Signals
Builds failing overnight with new analyser warnings promoted to errors; dotnet --version on the agent printing an 11.0 preview; no code changes in the failing repos.
Investigation
Compared dotnet --info output from a passing run the day before with a failing run and saw the SDK version change. Checked the repos and found no global.json. Reproduced locally by installing the same preview.
Resolution
Added global.json to each repo pinning 10.0.100 with rollForward latestFeature and allowPrerelease false, and switched pipelines to UseDotNet@2 with useGlobalJson. Builds were green again without touching code.
Prevention
A repo template includes global.json; a pipeline check fails if global.json is missing; agent image changes are announced and tested against a canary set of repos; SDK upgrades go through a deliberate pull request.

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.

↩ Usually asked after

More in Project System

Last updated 1 Nov 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me