Skip to content

MSBuild

dotnet build looks like a single command doing one thing. Underneath, it's invoking MSBuild — the build engine that has shipped with every version of .NET and Visual Studio since 2003 — against your .csproj file, which is itself an MSBuild project file, not a magic format the dotnet CLI invented. Anyone who's opened a .csproj from before 2017 and one from after has seen two wildly different amounts of XML for what's nominally the same job; that gap is the SDK-style rewrite, and it's worth understanding both what changed and why the underlying engine didn't.

A .csproj is a build graph, not project metadata

The pre-2017 .csproj listed every source file explicitly:

<ItemGroup>
  <Compile Include="Program.cs" />
  <Compile Include="Utils.cs" />
  <Compile Include="Models\User.cs" />
</ItemGroup>

The SDK-style format that shipped with .NET Core/5+ inverts this: C# files are included by default via a glob, so a typical modern .csproj is nearly empty:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
  </ItemGroup>
</Project>

Sdk="Microsoft.NET.Sdk" is the important part — it imports a large set of default targets, properties, and item globs that used to be spelled out by hand (or generated by Visual Studio) in every project file. This is Maven's "convention over configuration" move, arriving at MSBuild about 13 years later: implicit defaults instead of explicit line-by-line declarations, with an escape hatch (adding <ItemGroup>/<PropertyGroup> entries) for anything the default doesn't cover.

An "SDK" is just more MSBuild, stacked

Sdk="Microsoft.NET.Sdk" doesn't select a compiler or a runtime — it resolves to a directory (shipped with the .NET SDK, or fetched as a NuGet package for other SDKs) containing plain .props and .targets files, imported at the very start and very end of your project file respectively:

Sdk.props   →  <your .csproj content>  →  Sdk.targets

Sdk.props, evaluated first, sets defaults your file can still override (default output paths, default language version, the implicit item globs for .cs files). Sdk.targets, imported last, defines the actual Compile, Build, and Publish targets that do the work, wired up via BeforeTargets/AfterTargets/DependsOnTargets hooks that your own project-level targets can slot into. There is no separate "SDK engine" — Microsoft.NET.Sdk is authored in exactly the XML this chapter has been showing, just a great deal more of it, and reading it (it lives under the installed SDK's Sdks/Microsoft.NET.Sdk/Sdks or .../targets folders) is a legitimate way to answer "where does this default actually come from," the same way mvn help:effective-pom answers it for Maven.

This is also why there are so many different Sdk= values — Microsoft.NET.Sdk.Web, Microsoft.NET.Sdk.Razor, Microsoft.NET.Sdk.BlazorWebAssembly, Microsoft.NET.Sdk.Worker, Microsoft.NET.Sdk.WindowsDesktop (WPF/WinForms) — each is its own NuGet-distributed stack of .props/.targets, and most of them work by importing Microsoft.NET.Sdk themselves and layering additional targets on top, rather than reimplementing project building from scratch. A Blazor WebAssembly project is, mechanically, an ordinary SDK-style .NET project plus an extra import that adds targets for producing a _framework/ folder of WASM-runnable assemblies — "SDK" here means "a reusable pile of MSBuild logic packaged like a library," not a separate build system underneath the one this chapter describes.

Tracing the implicit glob to its actual source

The claim above — "C# files are included by default via a glob" — isn't hand-waving; it's one specific ItemGroup, in one specific file, shipped with the SDK. On this machine (.NET SDK 10.0.101) it's Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.DefaultItems.props:

<ItemGroup Condition=" '$(EnableDefaultItems)' == 'true' ">
  <Compile Include="**/*$(DefaultLanguageSourceExtension)"
           Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)"
           Condition=" '$(EnableDefaultCompileItems)' == 'true' " />
  ...
</ItemGroup>

$(DefaultLanguageSourceExtension) is .cs for the C# SDK (.vb/.fs for the others) — this line is the entire "why does adding a .cs file to the folder just work" mechanism, no IDE magic involved, and it's disableable per-project with <EnableDefaultCompileItems>false</EnableDefaultCompileItems> for anyone who wants the pre-2017-style explicit <Compile Include> list back.

$(DefaultItemExcludes) is worth following too, since it's assembled piecemeal across several .targets files and explains a recurring "why isn't my file compiling" report. The entry that matters most lives in Microsoft.NET.DefaultOutputPaths.targets:

<DefaultItemExcludes>$(DefaultItemExcludes);bin/**;obj/**</DefaultItemExcludes>

bin/ and obj/ are excluded from every implicit glob, on purpose — without this, a build would glob-include its own previous output back into the next compile, including generated .cs files MSBuild itself writes into obj/. It's also why copying or extracting a stray .cs file into bin//obj/ (an easy mistake in a script that shells out to the build) silently produces a file that never compiles: not a bug in the glob, working exactly as specified, just non-obvious until this file has been read once.

Properties, items, targets, tasks — the four building blocks

Everything in MSBuild is one of these:

  • Properties are named values — <TargetFramework>net8.0</TargetFramework> is a property. Read them with $(TargetFramework).
  • Items are named lists of files or values — <Compile>, <PackageReference>. Read them with @(Compile).
  • Targets are named, ordered groups of work with their own dependency relationships — the MSBuild equivalent of a Gradle task or a Make rule.
  • Tasks are the actual units of execution a target invokes — Csc (the C# compiler task), Copy, Exec.

A hand-written target makes the shape concrete:

<Target Name="PrintVersion" BeforeTargets="Build">
  <Message Text="Building version $(Version)" Importance="high" />
</Target>

BeforeTargets="Build" is MSBuild's dependency mechanism — it says "run this before the Build target," without needing to modify Build itself or know its internal implementation. AfterTargets is the mirror image. This is deliberately looser than Make's or Gradle's explicit dependsOn: it lets you hook into a target defined somewhere in an imported .targets file you've never opened, which is both the main way people customize a .NET build and the main way two unrelated NuGet packages' BeforeTargets hooks end up firing in an order nobody explicitly chose.

Two phases: evaluation, then execution

MSBuild processes a project in two strictly separated passes, and most "why did my property/item not take effect" confusion comes from not knowing which pass you're editing.

Evaluation runs first, top to bottom, through the project file and every file it <Import>s: properties are assigned in document order, item globs (<Compile Include="**/*.cs" />) are expanded against the filesystem as they're encountered, and Condition attributes are evaluated using whatever property values exist at that point in the file. Nothing here runs a target or a task — it's pure data assembly, building up the in-memory property and item tables that targets will read from later.

Execution runs second: MSBuild determines which targets to run and in what order (from DependsOnTargets, BeforeTargets, AfterTargets, and the target(s) requested on the command line), then runs their tasks in order, in a single pass over the already-evaluated property and item values.

The consequence that catches people: a <PropertyGroup> or item modification written inside a target only exists during execution, and can't retroactively change something whose value was already locked in during evaluation, even though both look like ordinary XML sitting in the same file:

<PropertyGroup>
  <IncludeExtra>false</IncludeExtra>
</PropertyGroup>

<ItemGroup>
  <Compile Include="Extra.cs" Condition="'$(IncludeExtra)' == 'true'" />
</ItemGroup>

<Target Name="EnableExtra" BeforeTargets="Build">
  <PropertyGroup>
    <IncludeExtra>true</IncludeExtra>
  </PropertyGroup>
</Target>

This looks like it should include Extra.cs: EnableExtra sets IncludeExtra to true, and BeforeTargets="Build" guarantees it runs before the build proper starts. It doesn't. The Compile item's Condition was already evaluated, against IncludeExtra's document-order value of false, during the evaluation pass — before any target, including EnableExtra, has run at all. Extra.cs is permanently absent from @(Compile) for this build, and nothing in a normal build log calls that out as a mistake, because as far as evaluation was concerned, the condition was simply false. The fix isn't a different target hook — it's moving IncludeExtra's real value somewhere evaluation can see it before the item is expanded (a -property:IncludeExtra=true command-line switch, or an <Import> ordered earlier in the file), because no BeforeTargets placement can run early enough to matter.

Targets form a graph too — just via imports, not a single file

A real .csproj is rarely just the file you're looking at. The Sdk attribute alone pulls in Sdk.props and Sdk.targets — files that live in the installed .NET SDK, not the repo — and any NuGet package with build logic (analyzers, source generators, native interop) adds its own .targets file via <Import>. dotnet build -bl produces a binary log (openable with the MSBuild Structured Log Viewer) that's the actual answer to "what ran and in what order" — reading the .csproj alone tells you the project's own targets, not the full graph assembled from every SDK and package import, which is exactly why binary log inspection, not .csproj reading, is the real debugging tool once a build does something surprising.

Items carry metadata, not just filenames

An item isn't just a path — it can carry arbitrary key/value metadata, which is how MSBuild expresses per-file build behavior without a separate target for every file:

<ItemGroup>
  <Content Include="config.json">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </Content>
</ItemGroup>

CopyToOutputDirectory is metadata on this specific Content item — %(CopyToOutputDirectory) inside a target reads it back per-item during a Copy task's batching. This item/metadata model is the part of MSBuild with the least direct equivalent in Make, Maven, or Gradle: those systems mostly bind behavior to file type or task type; MSBuild lets a single file carry its own build instructions inline.

PackageReference, traced from XML to compiler argument

<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> doesn't turn into a compiler argument directly — there's a concrete multi-step pipeline between them, spread across a restore step and a build step that only agree via a file on disk, and every step of it is inspectable.

1. dotnet restore resolves the dependency graph and writes it to obj/project.assets.json — not just to the NuGet cache. It's a JSON map from every resolved package to the actual files it contributes for each purpose (compile-time reference, runtime asset, build-time .targets, analyzer, ...). For the package above, restored for real, the relevant slice is:

"targets": {
  "net10.0": {
    "Newtonsoft.Json/13.0.3": {
      "type": "package",
      "compile": {
        "lib/net6.0/Newtonsoft.Json.dll": { "related": ".xml" }
      },
      "runtime": {
        "lib/net6.0/Newtonsoft.Json.dll": { "related": ".xml" }
      }
    }
  }
}

2. The ResolvePackageAssets task reads that file back during build — a separate MSBuild invocation from restore, wired up in Microsoft.PackageDependencyResolution.targets. Its CompileTimeAssemblies output — one entry per "compile" entry above — becomes the ResolvedCompileFileDefinitions item list.

3. ResolveLockFileReferences folds those into ordinary @(Reference) items — the same item type a hand-written <Reference Include="System.Xml" /> produces, carrying a HintPath metadata value that points at the actual DLL on disk. Dropping a one-line diagnostic target into a throwaway project confirms the whole chain end to end:

<Target Name="PrintReferences" AfterTargets="ResolveLockFileReferences">
  <Message Text="REF: %(Reference.Identity) HintPath=%(Reference.HintPath)" Importance="high" />
</Target>
REF: /home/.../.nuget/packages/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll
     HintPath=/home/.../.nuget/packages/newtonsoft.json/13.0.3/lib/net6.0/Newtonsoft.Json.dll

— resolved straight into the shared package cache under ~/.nuget/packages/, with no copy into the project folder. Csc then reads @(Reference) like any other input item, with no idea that some entries came from a NuGet package and others from a hand-written <Reference> element — by the time compilation runs, that distinction is already gone. This is also the answer to "how can a build compile against a dependency without dotnet restore ever copying a file into the repo": it doesn't need to. MSBuild references packages in place from the global cache; project.assets.json is just the map that tells it where.

MSBuild as a small programming language, not just config

XML syntax hides it, but MSBuild has real expression evaluation baked into $() and @(), enough that project authors write actual logic without ever leaving project-file syntax.

Property functions call real .NET static or instance methods directly from a property expression:

<PropertyGroup>
  <BuildTimestamp>$([System.DateTime]::Now.ToString("yyyyMMdd"))</BuildTimestamp>
  <IsRelease>$(Configuration.ToUpperInvariant() == 'RELEASE')</IsRelease>
</PropertyGroup>

$([System.DateTime]::Now...) is calling into the BCL from inside an XML attribute — a deliberately whitelisted set of "safe" types and methods, not arbitrary reflection, but real method calls with real return values nonetheless.

Item functions and transforms do the equivalent for lists — filter, dedupe, reshape:

<ItemGroup>
  <UniqueExtensions Include="@(Compile->'%(Extension)'->Distinct())" />
</ItemGroup>

->'%(Extension)' transforms each item to just its extension metadata; ->Distinct() then dedupes the resulting list — a small pipeline of list operations, expressed inline, doing in one line what would otherwise need a custom task.

And for logic too involved for expressions, UsingTask with RoslynCodeTaskFactory lets a project file define a task as inline C#, compiled on the fly the first time it's used:

<UsingTask TaskName="DoubleNumber" TaskFactory="RoslynCodeTaskFactory"
           AssemblyFile="$(RoslynTargetsPath)\Microsoft.Build.Tasks.CodeTaskFactory.dll">
  <ParameterGroup>
    <Input ParameterType="System.Int32" Required="true" />
    <Output ParameterType="System.Int32" Output="true" />
  </ParameterGroup>
  <Task>
    <Code Type="Fragment" Language="cs">
      Output = Input * 2;
    </Code>
  </Task>
</UsingTask>

Put together, these three mechanisms are why a "just a config file" format regularly ends up hosting real conditional logic, string manipulation, and even inline compiled code. A custom build step in MSBuild isn't only a prebuilt task DLL loaded via UsingTask AssemblyFile=... — it can be C# written directly in the project file, with no separate task project, build, or publish step at all.

Incremental targets: MSBuild's own timestamp check

MSBuild's built-in targets (Build, Compile, etc.) don't recompile everything on every invocation, and the mechanism is Make's, transplanted almost unchanged: a Target can declare Inputs and Outputs, and if every file in Outputs is newer than every file in Inputs, MSBuild skips the target's tasks entirely and logs Skipping target "X" because all output files are up-to-date.

<Target Name="GenerateVersionFile"
        Inputs="$(MSBuildProjectFile)"
        Outputs="$(IntermediateOutputPath)version.g.cs">
  <WriteLinesToFile File="$(IntermediateOutputPath)version.g.cs"
                     Lines="// $(Version)" Overwrite="true" />
</Target>

Running the same build twice in a row, with dotnet build -v:detailed, shows exactly this decision being made and logged:

Building target "GenerateVersionFile" completely.
Input file "MyApp.csproj" is newer than output file "obj/Debug/net8.0/version.g.cs".

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

First run: the output doesn't exist yet, so the target runs in full. Second run, nothing touched: -v:detailed logs the exact newer-than-comparison MSBuild made and the target is skipped entirely — no WriteLinesToFile task execution, no timestamp change on version.g.cs.

This is exactly the timestamp-comparison model the Make chapter describes — with the same failure mode: a build step that writes an output whose declared Inputs don't actually capture everything the task body reads (an environment variable, a file the task opens without declaring it) will go stale silently, because MSBuild only ever compares the files it was told about. dotnet build -v:detailed surfaces the up-to-date check MSBuild actually performed, which is the direct way to confirm whether a suspiciously-fast build skipped real work or genuinely had nothing to do.

Batching: one target, secretly many invocations

Referencing an item's metadata with %() instead of @() inside a task doesn't just read a value — it triggers batching: MSBuild groups the item list into buckets with identical metadata values and runs the containing task (or, if the reference is on the Target element itself, the whole target) once per bucket, not once total.

<Copy SourceFiles="@(Content)"
      DestinationFolder="$(OutDir)%(Content.RelativeDir)" />

Because %(Content.RelativeDir) differs per file, this single <Copy> element actually executes once per distinct destination directory, each invocation handling only the items sharing that value — not one Copy call per file, and not one call for everything at once either. Given three Content items — config.json at the project root, and icon.png/banner.png both under assets/ — a verbose build log shows this one XML element firing as two separate Copy invocations, not three and not one:

Task "Copy"
  Copying file from "config.json" to "bin/Debug/net8.0/config.json".
Done executing task "Copy".

Task "Copy"
  Copying file from "assets/icon.png" to "bin/Debug/net8.0/assets/icon.png".
  Copying file from "assets/banner.png" to "bin/Debug/net8.0/assets/banner.png".
Done executing task "Copy".

One bucket per distinct RelativeDir value — the empty string, and assets\ — with both assets/ files landing in the same invocation because they share a bucket, while the root-level file gets its own. This is usually invisible, because it produces the result people already expect ("preserve relative paths on copy"), but it's the mechanism behind two recurring surprises: a task that appears to run "more than once" in a verbose log with no corresponding loop anywhere in the XML, and a task that's supposed to batch (see all items at once, e.g. to produce a single combined output) but was accidentally written with %() instead of @() and now runs once per item instead.

Building many projects at once: /m and the project graph

A solution isn't one MSBuild evaluation — it's many .csproj files, each its own independent evaluation/execution pass, linked by <ProjectReference> items that tell MSBuild which other projects need to build first. dotnet build on a solution (or msbuild -m) builds this project-level graph in parallel: -m (or -maxCpuCount) spins up multiple MSBuild worker nodes, and independent projects — ones without a ProjectReference path between them — build concurrently, each in its own node/process.

This is a second, coarser dependency graph sitting above the target-level graph described earlier, and the two are easy to conflate: a ProjectReference guarantees project B finishes building before project A starts (so A can reference B's output DLL), but says nothing about ordering within either project's own targets. It's also where custom tasks that rely on process-wide static state get quietly broken — a task that stashes something in a static field expecting a later target in the same build to read it can end up running in a different node process entirely once /m parallelizes across projects, with no shared memory between them.

dotnet build vs. dotnet publish vs. MSBuild directly

dotnet build compiles and produces a runnable-in-place output — sufficient for local development, dotnet run, and F5 in an IDE. dotnet publish produces a self-contained deployable output: it resolves the full dependency closure needed to run outside the SDK's presence (or bundles a runtime entirely, for self-contained deployment), and applies publish-specific optimizations (trimming, ReadyToRun, single-file) that build intentionally skips because they're slow and unnecessary for an inner dev loop. Treating these as interchangeable — publishing what dotnet build produced, or expecting a CI build step's output to be deployable — is the most common MSBuild-adjacent deployment mistake, and it's a distinction the tool never hides, just one that's easy to skip past because both commands look similarly quick to type.

msbuild the raw executable still exists underneath dotnet build and dotnet publish both — they're a friendlier CLI wrapper that sets the right default properties and invokes the same engine. dotnet build -property:Configuration=Release and msbuild -p:Configuration=Release are doing the same underlying work; the dotnet CLI just exists so most .NET developers never need to know MSBuild's own command-line syntax at all.

Where people actually get burned

  • Multi-targeting and conditional items. A library targeting both net8.0 and netstandard2.0 uses <TargetFrameworks> (plural) plus a Condition on items or PackageReferences that only apply to one target:
<PropertyGroup>
  <TargetFrameworks>net8.0;netstandard2.0</TargetFrameworks>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="System.Text.Json" Version="8.0.0"
                     Condition="'$(TargetFramework)' == 'netstandard2.0'" />
</ItemGroup>

Forgetting the Condition on a framework-specific package reference — because System.Text.Json ships built into the runtime from net8.0 onward, but not on netstandard2.0 — is a build that succeeds for one target and fails to restore, or worse, compiles with the wrong API surface, for the other. Each TargetFramework in the list gets its own full evaluation pass, so a Condition typo'd against the wrong TFM string doesn't error, it just silently never matches.

  • Central Package Management (Directory.Packages.props) moving version numbers out of individual .csproj files is MSBuild's answer to the same problem Maven's <dependencyManagement> solves — a shared version pin across many projects:
<!-- Directory.Packages.props, at the repo root -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
  </ItemGroup>
</Project>
<!-- some-project.csproj — no Version attribute, it comes from above -->
<PackageReference Include="Newtonsoft.Json" />

It's easy to half-migrate a solution, leaving some projects still specifying their own Version on PackageReference — MSBuild doesn't reject that as a conflict, it silently lets the per-project Version override the central pin, which defeats the entire point of centralizing it in the first place without ever raising an error. - Directory.Build.props/Directory.Build.targets are auto-imported by every project below them in the directory tree, with no explicit <Import> needed:

<!-- repo-root/Directory.Build.props -->
<Project>
  <PropertyGroup>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
  </PropertyGroup>
</Project>

Every .csproj in every subfolder underneath repo-root/ picks up Nullable/TreatWarningsAsErrors automatically — genuinely useful for repo-wide settings, and a common source of "why does this property have a value I never set in this project" the first time someone encounters one three directories up from the .csproj they're actually looking at, since there's no <Import> line in the project file itself pointing back to it.