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, 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:
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:
->'%(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.
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.0andnetstandard2.0uses<TargetFrameworks>(plural) plus aConditionon items orPackageReferences 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.csprojfiles 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.