How a .NET Build Works

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.

Every .NET build, whatever starts it, follows the same path: the dotnet host picks an SDK, NuGet restores the packages, MSBuild evaluates the project and then executes its targets, and the compiler and copy tasks fill obj/ and bin/. Knowing this path is what turns build errors from mysteries into lookups: an "SDK not found" error belongs to the first step, NETSDK1004 to the second, a property that "doesn’t take effect" to the third, and a target that runs at the wrong moment to the fourth. This page follows one build of the Ledgerline consumer app, samples/Ledgerline.Report, through every step, with .NET SDK 10.0.402 (MSBuild 18.9.15, NuGet 7.9.0). The short introduction to dotnet build is in Build and Tooling; the full option list is on Restore, Build and Clean.

The files written by each phase of dotnet build: the source tree, restore output in obj/, build output in obj/Debug/net10.0 and the copied output in bin/Debug/net10.0

The phases at a glance

Phase What happens Owner Typical failure

Host and SDK resolution

dotnet (the "muxer") finds global.json, picks an installed SDK and loads its dotnet.dll

.NET host

"A compatible .NET SDK was not found"

Restore

The Restore target resolves PackageReference/ProjectReference graphs and writes obj/project.assets.json

NuGet (as MSBuild targets)

NU1101, NU1605, NU1107, NU1301

Startup

MSBuild parses the command line and response files, sets global properties, chooses loggers and nodes

MSBuild

MSB1009: Project file does not exist, MSB1011

Evaluation

Six passes build the in-memory project: properties, imports, item definitions, items, UsingTask, targets

MSBuild (+ the project SDK’s .props/.targets)

MSB4019 (missing import), MSB4236 (unknown SDK), NETSDK1045

Execution

Targets run in dependency order; tasks call the compiler, copy files, generate .deps.json

MSBuild + Roslyn + SDK tasks

CS* compiler errors, NETSDK1004, MSB3277, MSB3021

Outputs

obj/ holds intermediates; bin/<Configuration>/<TFM>/ holds the runnable output

SDK targets

stale or locked files (MSB3026/MSB3027)

Step 1 — the host picks an SDK

dotnet on the PATH is the muxer: a native host that decides whether its first argument is a command (build, test) or an application (dotnet app.dll). For a command it needs an SDK. It looks for a global.json in the current directory and each parent; if one exists, its sdk.version and sdk.rollForward choose among the installed SDKs, otherwise the newest installed SDK is used. The chosen SDK decides everything else on this page: the MSBuild version, the NuGet client, the C# compiler, the project SDKs and the default target framework of new projects.

Ledgerline pins the SDK in global.json (the keys are explained on Installing and Selecting SDKs):

{
  "sdk": {
    "version": "10.0.402",
    "rollForward": "latestPatch",
    "allowPrerelease": false
  },
  "test": {
    "runner": "Microsoft.Testing.Platform"
  }
}

When the pinned SDK is not installed — the classic case is a global.json updated by a developer whose CI agent still has the older SDK — the host fails before MSBuild even starts. With only SDK 10.0.402 installed, a global.json that asks for 10.0.500 gives:

The command could not be loaded, possibly because:
  * You intended to execute a .NET application:
      The application 'build' does not exist or is not a managed .dll or .exe.
  * You intended to execute a .NET SDK command:
      A compatible .NET SDK was not found.

Requested SDK version: 10.0.500
global.json file: ~/ledgerline/global.json

Installed SDKs:

Install the [10.0.500] .NET SDK or update [~/ledgerline/global.json] to match an installed SDK.

Learn about SDK resolution:
https://aka.ms/dotnet/sdk-not-found
10.0.402 [<dotnet>/sdk]

(The installed-SDK list is written to standard output after the standard-error text, which is why it appears at the end when both streams go to the same terminal.)

Step 2 — restore

Compilation needs to know which assemblies every package contributes for the target framework, so a build starts with a restore. dotnet build, run, test, pack and publish all run it implicitly; the documentation describes dotnet build as equivalent to dotnet msbuild -restore. -restore makes MSBuild run the Restore target in a separate evaluation and build first and then re-evaluate the project for the requested targets, because restore generates files that the second evaluation imports.

For each project, NuGet:

  1. reads the PackageReference, PackageVersion (Central Package Management) and ProjectReference items of every target framework, plus nuget.config for sources and source mapping;

  2. resolves the whole graph (lowest applicable version, nearest wins — see Dependency Resolution);

  3. takes each package from the global-packages folder (~/.nuget/packages) or downloads it, through the HTTP cache, from the mapped source;

  4. writes the restore outputs into obj/.

File Contents

obj/project.assets.json

The resolved graph per target framework: every package, its version, the exact lib/, ref/, runtimes/, build/ and analyzers/ files that apply. The build reads nothing else to resolve package references.

obj/<project>.csproj.nuget.g.props / .nuget.g.targets

Generated MSBuild files that import each package’s build/ or buildTransitive/ .props and .targets.

obj/<project>.csproj.nuget.dgspec.json

The dependency-graph specification restore was computed from (inputs).

obj/project.nuget.cache

A hash of the inputs; when it matches, the next restore is a no-op ("All projects are up-to-date for restore").

packages.lock.json (next to the project)

Only when RestorePackagesWithLockFile is true: the exact resolved versions with content hashes, to commit.

Running the build with the obj/ folder deleted but --no-restore shows what happens without the assets file:

rm -rf obj
dotnet build --no-restore -tl:off
<sdk>/Sdks/Microsoft.NET.Sdk/targets/Microsoft.PackageDependencyResolution.targets(266,5): error NETSDK1004: Assets file '~/ledgerline/samples/Ledgerline.Report/obj/project.assets.json' not found. Run a NuGet package restore to generate this file. [~/ledgerline/samples/Ledgerline.Report/Ledgerline.Report.csproj]

Build FAILED.
…
    0 Warning(s)
    1 Error(s)

The opposite mistake is quieter: changing a package version and then building with --no-restore uses the old assets file, so the build succeeds against the previous version. Use --no-restore only right after an explicit dotnet restore in the same job, which is exactly what CI does (CI Builds and Caching).

Step 3 — MSBuild startup

MSBuild starts by processing its command line: the project or solution to build (when none is given, the single project or solution file in the current folder; MSB1011 if there are several), global properties from -p:, the targets from -t:, loggers, verbosity and the node count. It also reads response files: MSBuild.rsp next to MSBuild.exe and Directory.Build.rsp in the project folder or any parent. dotnet build adds its own switches before calling MSBuild in-process; a diagnostic log (-v:diag) shows the full command line it built. For dotnet build --no-restore -tl:off in Ledgerline.Report:

dotnet build --no-restore -tl:off -v:diag | grep "Command line arguments"
                   Command line arguments = "<dotnet>/sdk/10.0.402/MSBuild -maxcpucount --verbosity:m -tlp:default=auto --property:NuGetInteractive=false --verbosity:diag --nologo -restore:false -tl:off -consoleloggerparameters:Summary -tlp:DISABLENODEDISPLAY"

-maxcpucount without a number (parallel build on all cores), minimal verbosity, the summary at the end and the Terminal Logger defaults are the CLI’s additions; the later --verbosity:diag wins over the earlier --verbosity:m. Without --no-restore the same line contains -restore instead of -restore:false, plus -restoreProperty: switches (EnableDefaultCompileItems=false and the other default-item globs) that make the separate restore evaluation cheaper by skipping the file globs restore does not need.

Global properties are the first trap of a build: a property passed with -p: (or set by Visual Studio, or by a parent project through the MSBuild task) overrides every assignment to that property in every project file, silently, unless the project uses TreatAsLocalProperty. dotnet build -p:Version=9.9.9 therefore wins over the <Version> in Ledgerline.csproj.

Step 4 — evaluation: six passes

Evaluation reads the project and every file it imports and builds an in-memory model. Nothing is built yet. It runs in six passes, and the pass order — not the order of the XML — is what decides which value a property or an item sees:

  1. Environment variables become properties ($(PATH), $(HOME), $(GITHUB_ACTIONS)).

  2. Imports and properties, in document order, with each <Import> expanded in place. Only properties outside targets are set. A property can read properties defined earlier, but not later ones, and no items at all.

  3. Item definitions (ItemDefinitionGroup: default metadata for an item type).

  4. Items outside targets, with their metadata. Because this pass comes after the whole property pass, items see the final value of every property.

  5. UsingTask elements declare the tasks available to targets.

  6. Targets: the target objects are created; none runs.

A ten-line project shows the consequences of the order:

<Project>
  <!-- Pass 2 (properties): JournalFolder is read before it is defined, so it is empty here. -->
  <PropertyGroup>
    <EarlyCopy>[$(JournalFolder)]</EarlyCopy>
    <JournalFolder>journals</JournalFolder>
    <LateCopy>[$(JournalFolder)]</LateCopy>
    <!-- Items are evaluated after all properties, so this is the literal text, not a file list. -->
    <JournalCount>@(Journal->Count())</JournalCount>
  </PropertyGroup>

  <!-- Pass 4 (items): runs after every property, so it sees the final JournalFolder. -->
  <ItemGroup>
    <Journal Include="$(JournalFolder)/*.csv" />
  </ItemGroup>

  <Target Name="Show">
    <!-- Execution phase: properties inside targets see the items. -->
    <PropertyGroup>
      <CountInTarget>@(Journal->Count())</CountInTarget>
    </PropertyGroup>
    <Message Importance="high" Text="EarlyCopy=$(EarlyCopy) LateCopy=$(LateCopy)" />
    <Message Importance="high" Text="JournalCount (evaluation)=$(JournalCount)" />
    <Message Importance="high" Text="CountInTarget (execution)=$(CountInTarget)" />
    <Message Importance="high" Text="Journal=@(Journal)" />
  </Target>
</Project>

-getProperty and -getItem (MSBuild 17.8 and later) print evaluated values without running any target, which is the quickest way to see what evaluation produced:

dotnet msbuild evaluation-order.proj -getProperty:EarlyCopy -getProperty:LateCopy -getProperty:JournalCount
dotnet msbuild evaluation-order.proj -t:Show -nologo -v:m
{
  "Properties": {
    "EarlyCopy": "[]",
    "LateCopy": "[journals]",
    "JournalCount": "@(Journal->Count())"
  }
}
  EarlyCopy=[] LateCopy=[journals]
  JournalCount (evaluation)=2
  CountInTarget (execution)=2
  Journal=journals/2026-01.csv;journals/2026-02.csv

EarlyCopy is empty because the property pass is strictly sequential. JournalCount shows a subtler rule: an item reference inside a property defined outside a target is stored as literal text (the evaluated value really is @(Journal->Count())), and it is expanded only later, wherever the property is used during execution — which is why the Message task still prints 2. Relying on this lazy expansion is fragile; compute item-derived values inside a target, as CountInTarget does.

The same order explains two classic bugs:

  • Reading $(TargetFramework) in Directory.Build.props. Directory.Build.props is imported at the very start of Microsoft.Common.props, before the project body sets TargetFramework. In a single-target project and in the outer build of a multi-targeted one the property is therefore still empty there; only in the inner, per-framework builds (where TargetFramework is a global property) does it have a value. The same condition thus works for one project and silently fails for another. Put TFM conditions in Directory.Build.targets or in the project file (demonstrated on Customizing the Build).

  • Items evaluated before the property they use is set — this one actually cannot happen within one project, because all properties are evaluated first; when an item seems to use a stale value, the property was set inside a target (execution phase) after the item was evaluated.

What a real project imports

An SDK-style project file is short because <Project Sdk="Microsoft.NET.Sdk"> adds two implicit imports, Sdk.props at the top and Sdk.targets at the bottom, and those pull in the rest. dotnet msbuild -pp:<file> writes the whole evaluated project, every import inlined with a comment naming its file. For Ledgerline.Report that is 116 distinct files; the ones that matter most, in import order (numbers are positions in that list):

dotnet msbuild -pp:report.pp.xml
  1 <sdk>/Sdks/Microsoft.NET.Sdk/Sdk/Sdk.props
  2 <sdk>/Current/Microsoft.Common.props
  3 ~/ledgerline/Directory.Build.props
  5 ~/ledgerline/samples/Ledgerline.Report/obj/Ledgerline.Report.csproj.nuget.g.props
  7 ~/ledgerline/Directory.Packages.props
  …   (Microsoft.NET.Sdk.props, DefaultItems.props, BundledVersions.props, SourceLink props, WindowsDesktop props)
    --- the project file body is evaluated here ---
 31 <sdk>/Sdks/Microsoft.NET.Sdk/Sdk/Sdk.targets
 59 <sdk>/Microsoft.CSharp.targets
 62 <sdk>/Roslyn/Microsoft.CSharp.Core.targets
 65 <sdk>/Microsoft.Common.targets
 66 <sdk>/Microsoft.Common.CurrentVersion.targets
 73 ~/ledgerline/samples/Ledgerline.Report/obj/Ledgerline.Report.csproj.nuget.g.targets
 74 ~/.nuget/packages/ledgerline.build/1.0.0/buildTransitive/Ledgerline.Build.targets
 75 ~/ledgerline/Directory.Build.targets
 77 <sdk>/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.targets
 …

Three things are visible: the repository’s Directory.Build.props is read before the project body and Directory.Build.targets after it; package build logic (Ledgerline.Build.targets, from the package’s buildTransitive/ folder) arrives through the restore-generated nuget.g.targets; and Microsoft.Common.targets, which defines Build, comes from the SDK, not from the project.

Step 5 — execution

In the execution phase MSBuild first evaluates the properties and items that are inside targets, in the order they run, then orders and runs the requested targets. Build itself is empty; it depends on $(BuildDependsOn) = BeforeBuild;CoreBuild;AfterBuild, and CoreBuild depends on a long list that ends in Compile (which calls the C# compiler in CoreCompile), the copy to the output folder and IncrementalClean. Within one project targets run one at a time; MSBuild orders them with DependsOnTargets, BeforeTargets and AfterTargets (Targets and Build Order). With -m (on by default for dotnet build), separate projects build in parallel on worker nodes.

A detailed log (-v:d) names every target as it starts. For Ledgerline.Report, the 148 targets run by the main build include, in this order (abridged):

dotnet build -tl:off -v:d > build.log
grep -E 'Target "[^"]+" in (file|project)' build.log
_CheckForUnsupportedNETCoreVersion … ProcessFrameworkReferences … CollectPackageReferences …
BeforeBuild BuildOnlySettings GetFrameworkPaths GetReferenceAssemblyPaths PrepareForBuild
GenerateTargetFrameworkDefineConstants … GenerateGlobalUsings BeforeResolveReferences …
ResolvePackageAssets ResolveLockFileReferences ResolveLockFileAnalyzers ResolveLockFileCopyLocalFiles …
ResolveProjectReferences ResolveFrameworkReferences ResolveTargetingPackAssets … ResolveAssemblyReferences
AfterResolveReferences ResolveReferences … AssignTargetPaths … PrepareResources …
GenerateMSBuildEditorConfigFile … InitializeSourceControlInformation AddSourceRevisionToInformationalVersion
GetAssemblyAttributes CoreGenerateAssemblyInfo GenerateAssemblyInfo BeforeCompile …
LedgerlineValidateJournals … GenerateSourceLinkFile … CoreCompile AfterCompile _CreateAppHost Compile …
GetCopyToOutputDirectoryItems … _CopyFilesMarkedCopyLocal … GenerateBuildDependencyFile
GenerateBuildRuntimeConfigurationFiles CopyFilesToOutputDirectory PrepareForRun … IncrementalClean
CoreBuild AfterBuild Build

LedgerlineValidateJournals — a target from the Ledgerline.Build package with BeforeTargets="CoreCompile" — runs right before the compiler, which is how package build logic hooks itself into everyone else’s build without editing their project files. ResolvePackageAssets is where the assets file from step 2 is read.

A second build of an unchanged project skips the expensive targets, because their Inputs are not newer than their Outputs:

       Skipping target "LedgerlineValidateJournals" because all output files are up-to-date with respect to the input files.
       Skipping target "CoreCompile" because all output files are up-to-date with respect to the input files.
       Skipping target "GenerateBuildDependencyFile" because all output files are up-to-date with respect to the input files.

A target without Inputs and Outputs runs on every build — the most common reason why a "nothing changed" build is slow (Incremental Builds and Batching).

The common entry-point targets

Target What it does Started by

Restore

NuGet restore of the project (and its project references)

dotnet restore, -restore, implicit restore

Build

BeforeBuild → CoreBuild (resolve references, compile, copy) → AfterBuild

dotnet build, msbuild (default target)

Rebuild

Clean then Build

dotnet build --no-incremental, msbuild -t:Rebuild

Clean

deletes the files recorded in obj/<project>.csproj.FileListAbsolute.txt

dotnet clean

Pack

builds (unless NoBuild) and writes the .nupkg/.snupkg

dotnet pack, msbuild -t:Pack

Publish

builds, then copies the app and its dependencies (optionally trimmed, single-file, AOT) to the publish folder

dotnet publish, msbuild -t:Publish

VSTest / Test

runs tests (VSTest mode / Microsoft.Testing.Platform mode)

dotnet test

Step 6 — the outputs

obj/ holds everything intermediate: the restore files, then per configuration and TFM (obj/Debug/net10.0/) the generated sources (*.AssemblyInfo.cs, *.GlobalUsings.g.cs), the generated .editorconfig that analyzers read, the compiled assembly before it is copied, the reference assembly (ref/*.dll, metadata-only; the SDK produces it by default, controlled by ProduceReferenceAssembly), its internal twin (refint/) and the cache files that make the next build incremental. bin/Debug/net10.0/ holds what runs:

find obj bin -maxdepth 4 -type f | sort
bin/Debug/net10.0/Ledgerline.Report
bin/Debug/net10.0/Ledgerline.Report.deps.json
bin/Debug/net10.0/Ledgerline.Report.dll
bin/Debug/net10.0/Ledgerline.Report.pdb
bin/Debug/net10.0/Ledgerline.Report.runtimeconfig.json
bin/Debug/net10.0/Ledgerline.dll
bin/Debug/net10.0/journal.ledger.csv
obj/Debug/net10.0/.NETCoreApp,Version=v10.0.AssemblyAttributes.cs
obj/Debug/net10.0/Ledgerli.B93671E4.Up2Date
obj/Debug/net10.0/Ledgerline.Report.AssemblyInfo.cs
obj/Debug/net10.0/Ledgerline.Report.AssemblyInfoInputs.cache
obj/Debug/net10.0/Ledgerline.Report.GeneratedMSBuildEditorConfig.editorconfig
obj/Debug/net10.0/Ledgerline.Report.GlobalUsings.g.cs
obj/Debug/net10.0/Ledgerline.Report.assets.cache
obj/Debug/net10.0/Ledgerline.Report.csproj.AssemblyReference.cache
obj/Debug/net10.0/Ledgerline.Report.csproj.CoreCompileInputs.cache
obj/Debug/net10.0/Ledgerline.Report.csproj.FileListAbsolute.txt
obj/Debug/net10.0/Ledgerline.Report.dll
obj/Debug/net10.0/Ledgerline.Report.genruntimeconfig.cache
obj/Debug/net10.0/Ledgerline.Report.pdb
obj/Debug/net10.0/apphost
obj/Debug/net10.0/ledgerline.validated
obj/Debug/net10.0/ref/Ledgerline.Report.dll
obj/Debug/net10.0/refint/Ledgerline.Report.dll
obj/Ledgerline.Report.csproj.nuget.dgspec.json
obj/Ledgerline.Report.csproj.nuget.g.props
obj/Ledgerline.Report.csproj.nuget.g.targets
obj/project.assets.json
obj/project.nuget.cache

For an executable, the build output is directly runnable: since .NET Core 3.0 the package dependencies (Ledgerline.dll here) are copied next to the app, a native launcher (Ledgerline.Report, the apphost; .exe on Windows) is created from the SDK’s apphost template, and two JSON files tell the runtime what to load:

{
  "runtimeOptions": {
    "tfm": "net10.0",
    "framework": {
      "name": "Microsoft.NETCore.App",
      "version": "10.0.0"
    },
    "configProperties": {
      "System.Runtime.Serialization.EnableUnsafeBinaryFormatterSerialization": false
    }
  }
}

Ledgerline.Report.runtimeconfig.json (above) names the shared framework and its minimum version; the host rolls forward to the newest installed patch. Ledgerline.Report.deps.json lists every dependency and the path of each assembly inside its package ("lib/net10.0/Ledgerline.dll"), so the host can load them from the output folder. A library gets a .deps.json but no .runtimeconfig.json and no apphost. dotnet publish produces the same files for deployment (Publish and Deployment Models).

The output folders can be moved out of the project with the artifacts output layout (ArtifactsPath or UseArtifactsOutput, .NET 8 and later): all projects then write to artifacts/bin/<project>/<pivot>/ and artifacts/obj/<project>/.

Who contributes what

Part Contribution to every build

.NET host (dotnet)

Finds global.json, selects the SDK, starts the CLI; later starts the app (dotnet app.dll or the apphost)

dotnet CLI

Translates commands and options to MSBuild invocations (build → msbuild -restore, pack → -t:Pack), adds defaults (parallel build, Terminal Logger), runs non-MSBuild commands (new, tool, workload, nuget push)

Project SDK (Microsoft.NET.Sdk)

Sdk.props/Sdk.targets: defaults (globs, implicit usings, output paths), framework references, the .deps.json and apphost targets, publish, pack integration, Source Link

MSBuild

Evaluation, target ordering, incremental checks, parallel nodes, logging, the common targets (Microsoft.Common.targets) and built-in tasks (Copy, Exec, MSBuild)

NuGet

Restore and Pack targets, the assets file, the global-packages folder, nuget.g.props/.targets for package build logic

Roslyn

The Csc task in CoreCompile: compilation, analyzers, source generators

IDEs drive the same pipeline

Visual Studio, Rider and VS Code with the C# Dev Kit all build with MSBuild and the selected SDK, so a project that builds with dotnet build builds in the IDE and vice versa. The documented differences are in how they call it: Visual Studio manages the project build order itself, builds each project with its own MSBuild invocation, does not create the metaproject that MSBuild generates for a command-line solution build, and runs frequent design-time builds (evaluations plus a few targets, with DesignTimeBuild=true) to populate IntelliSense. Conditions on $(BuildingInsideVisualStudio) or $(DesignTimeBuild) are therefore the only legitimate way for a project to behave differently in the IDE.

Common errors along the pipeline

Message (abridged) Step and fix

A compatible .NET SDK was not found. Requested SDK version: …

Host. Install the SDK global.json asks for, or relax rollForward.

NETSDK1045: The current .NET SDK does not support targeting .NET 11.0.

Evaluation. The project targets a newer TFM than the SDK knows; install a newer SDK.

MSB4236: The SDK 'Microsoft.Build.NoTargets' specified could not be found.

Evaluation. An MSBuild project SDK could not be resolved (missing version or feed).

MSB4019: The imported project "…" was not found.

Evaluation. An <Import> path is wrong or the file was not restored.

NETSDK1004: Assets file '…project.assets.json' not found.

Execution. Run a restore (or drop --no-restore).

NU1101: Unable to find package …

Restore. Package ID typo, missing source, or source mapping excludes the source.

MSB3277: Found conflicts between different versions of "…" that could not be resolved.

Execution (ResolveAssemblyReferences). Align the versions or add the binding.

Each error has its own page in the MSBuild, SDK and NuGet troubleshooting pages.

Release notes

  • MSBuild 17.8 added -getProperty, -getItem and -getTargetResult.

  • .NET 8 introduced the artifacts output layout and made Source Link part of the SDK.

  • .NET 9 made the Terminal Logger the default for interactive builds (--tl:auto).

  • MSBuild 17.12 builds .slnx solutions; .NET 10 makes .slnx the default for dotnet new sln.

The release-by-release lists are on the MSBuild and .NET SDK history pages.