MSBuild fundamentals: the evaluation and execution phases, properties, items and metadata, targets and their ordering, tasks, global properties, incremental builds with Inputs and Outputs, and how dotnet build maps onto MSBuild.
MSBuild is the build engine under dotnet build, Visual Studio and Rider, and a .csproj is an MSBuild file. Properties are named values such as $(Configuration). Items are lists with metadata, such as @(Compile) holding every .cs file. Targets are named steps, ordered with DependsOnTargets, BeforeTargets and AfterTargets. Each target runs tasks, the real units of work, such as Csc to compile or Copy. The build has two phases. Evaluation reads imports, properties and items. Execution runs the targets. Targets that declare Inputs and Outputs are skipped when nothing has changed, which is what makes builds incremental.
"MSBuild is the build engine behind
dotnet build, Visual Studio and Rider, and a.csprojis just an MSBuild XML file. It has four building blocks. Properties are named single values, like$(Configuration); items are typed lists with metadata, like@(Compile)holding every.csfile, with%(Filename)on each one; targets are named steps; and tasks are the units of work inside a target, likeCscto compile orCopy, implemented as .NET classes.A build runs in two phases. Evaluation loads the project and all its imports, the SDK props and targets plus files like
Directory.Build.props, and works out all properties first, then items. Execution then runs the requested target,Buildfordotnet build, plus everything it depends on, ordered withDependsOnTargets,BeforeTargetsandAfterTargets. That split explains a lot of surprises: a property set inside a target isn't visible to conditions evaluated earlier. A-p:Name=Valueon the command line is a global property that beats the project file, which is how-c ReleasesetsConfiguration.A target that declares
InputsandOutputsis skipped when its outputs are newer than its inputs, which is what makes a second build fast. The mistake I see is custom targets without them, so they run every time and can force everything downstream to recompile. So I prefer properties over custom targets, hook in withBeforeTargetsrather than overriding SDK targets, and when a build behaves strangely I open a binary log from-blinstead of guessing."
| Concept | Syntax | Example |
|---|---|---|
| Property | <PropertyGroup><Name>value</Name></PropertyGroup>, where the element name is the property name, read with $(Name) |
$(Configuration), $(TargetFramework), $(OutputPath) |
| Item | <ItemGroup><Compile Include="*.cs" /></ItemGroup>, where the element name is the item type, read as a list with @(Compile) |
@(Compile), @(PackageReference), @(EmbeddedResource) |
| Item metadata | %(Name): a value attached to each item |
%(Filename), %(Extension), %(CopyToOutputDirectory) |
| Target | <Target Name="X" DependsOnTargets="Y"> |
Restore, Build, CoreCompile, Publish |
| Task | An element inside a target | <Csc>, <Copy>, <Exec>, <Message> |
Directory.Build.props, your project, SDK targets, Directory.Build.targets). It evaluates all properties first in file order, then all items. Conditions on properties, items and imports are checked here; conditions on targets are checked later, when each target is about to run.dotnet build, Build) and every target it depends on, in order. Targets run at most once per build.Understanding the phases explains common surprises: a property set inside a target isn't visible during evaluation, and an item can't be conditioned on a property that's only set later in another file.
dotnet build runs MSBuild with an implicit restore and the Build target. The SDK's Build target chain includes, roughly, ResolveReferences → CoreCompile (runs the Csc task, the C# compiler) → CopyFilesToOutputDirectory. You can run MSBuild directly with dotnet msbuild:
dotnet msbuild -t:Build -p:Configuration=Release
dotnet msbuild -getProperty:TargetFramework -getProperty:OutputPath
-p:Name=Value on the command line sets a global property, which wins over the same property set in the project. That's how -c Release works (it sets Configuration).
A target with Inputs and Outputs is skipped when all outputs are newer than all inputs. That's why a second dotnet build with no changes is fast. Custom targets without Inputs/Outputs run every time, and if they touch a file that other targets read, they can force rebuilds of everything downstream.
A team added a custom target that regenerated a version file on every build. Because it always wrote the file, CoreCompile saw a changed input and recompiled every project, every time. Local builds went from 5 seconds to 50. Adding Inputs and Outputs, and only writing the file when its content changed, restored incremental builds.
Inputs and Outputs, and hook in with BeforeTargets/AfterTargets instead of overriding SDK targets.Exec runs shell commands with the build's permissions; treat build scripts as code that needs review, especially in shared repos.Checks that the candidate understands the engine behind their builds.
Green flags: explains properties, items, targets and tasks; knows evaluation versus execution; mentions incremental builds and -bl.
Red flags: treats the project file as opaque; can't say what dotnet build does underneath; writes targets that run every time.
MSBuild is a general build engine. It reads XML project files, works out a set of values (properties and items), then runs named steps (targets) made of units of work (tasks). The .NET SDK is mostly a large collection of MSBuild .props and .targets files plus tasks written in C#.
| Front end | What it does with MSBuild |
|---|---|
dotnet build |
Runs MSBuild with an implicit restore and the Build target |
dotnet msbuild |
Runs MSBuild directly with any switches |
| Visual Studio, Rider, C# Dev Kit | Evaluate projects for the IDE (design-time builds) and run MSBuild to build |
CI tasks (DotNetCoreCLI@2, plain scripts) |
Usually call dotnet build, so the same engine |
| Concept | Declared as | Read as | Example |
|---|---|---|---|
| Property | <PropertyGroup><Configuration>Release</Configuration></PropertyGroup> (the element name is the property name) |
$(Configuration) |
$(TargetFramework), $(OutputPath), $(MSBuildProjectDirectory) |
| Item | <ItemGroup><Compile Include="**/*.cs" /></ItemGroup> (the element name is the item type) |
@(Compile) (a list, joined with ;) |
@(PackageReference), @(EmbeddedResource), @(None) |
| Item metadata | Child elements or attributes on an item | %(Compile.Filename) or %(Filename) |
Well-known: %(FullPath), %(Filename), %(Extension), %(RelativeDir); custom: %(CopyToOutputDirectory) |
| Target | <Target Name="X" DependsOnTargets="Y"> |
Run with -t:X |
Restore, Build, CoreCompile, Publish, Pack |
| Task | An element inside a target | <Csc>, <Copy>, <Exec>, <Message>, <WriteLinesToFile> |
Useful expressions:
| Expression | Meaning |
|---|---|
$([System.IO.Path]::Combine($(A), 'b')) |
Property function: call a whitelisted .NET method |
$([MSBuild]::GetPathOfFileAbove('global.json', '$(MSBuildThisFileDirectory)')) |
MSBuild intrinsic function |
@(Compile->'%(Filename).g.cs') |
Item transform: a new list built from metadata |
@(Compile, ' ') |
Item list joined with a custom separator |
%(Compile.RelativeDir) inside a task |
Batching: run the task once per distinct value |
EVALUATION (no side effects, cached by the IDE)
------------------------------------------------
0. environment variables, global properties (-p:), reserved properties
1. properties and imports, in file order
Sdk.props -> Directory.Build.props -> Orders.csproj -> Sdk.targets -> Directory.Build.targets
2. item definitions
3. items (globs expanded, Include/Remove/Update applied)
4. UsingTask declarations
5. targets collected (not run)
|
v
EXECUTION (targets run, files are written)
------------------------------------------------
requested target, e.g. Build
-> its DependsOnTargets, in order
-> targets that say BeforeTargets="Build"
-> Build itself (condition checked now)
-> targets that say AfterTargets="Build"
each target runs at most once per project instance
Consequences worth remembering:
| Situation | What happens |
|---|---|
| A property group condition refers to a property set later in another file | It sees the earlier value (often empty) |
| An item uses a property | Works: all properties are final before items are evaluated |
A target sets a property with <PropertyGroup> inside it |
Visible to later targets, but not to anything evaluated earlier |
A target's Condition |
Checked when the target is about to run, so it can depend on what earlier targets did |
-p:Name=Value |
Global property: overrides the project's value for that build |
dotnet build -c Release
= msbuild -restore -t:Build -p:Configuration=Release -m -consoleloggerparameters:Summary ...
Restore -> obj/project.assets.json, nuget.g.props, nuget.g.targets
Build
|-- BeforeBuild
|-- CoreBuild
| |-- PrepareForBuild (create obj/ and bin/ folders)
| |-- ResolveReferences (packages, projects, framework references)
| |-- GenerateAssemblyInfo, GenerateGlobalUsings, source generators run in Csc
| |-- CoreCompile [Inputs: @(Compile), references, analysers ...]
| | `-- Csc task [Outputs: obj/Release/net10.0/Orders.dll]
| |-- CopyFilesToOutputDirectory
| `-- IncrementalClean (deletes stale files it wrote earlier)
`-- AfterBuild
| CLI option | MSBuild equivalent |
|---|---|
-c Release |
-p:Configuration=Release |
-f net10.0 |
-p:TargetFramework=net10.0 |
-r linux-x64 |
-p:RuntimeIdentifier=linux-x64 |
--no-restore |
Omits -restore |
-v:d, -bl, -m, -graph |
Passed straight to MSBuild |
Since .NET 9 the terminal logger is the default in interactive terminals: a compact, live view of projects. Use -tl:off or -v:n when you need classic output, and -bl when you need everything.
<Target Name="GenerateClients"
BeforeTargets="CoreCompile"
Inputs="@(OpenApiSpec)"
Outputs="@(OpenApiSpec->'$(IntermediateOutputPath)%(Filename).g.cs')">
...
</Target>
| Inputs vs Outputs | MSBuild does |
|---|---|
| Every output newer than every input | Skips the target ("Skipping target ... because all output files are up-to-date") |
| Some inputs newer, and outputs map 1:1 to inputs (a transform) | Partial build: runs only for the changed items |
| Any output missing | Runs the target |
No Inputs/Outputs declared |
Runs every time |
Two things break incrementality more than anything else:
CoreCompile (a version file, a generated config). Every project then recompiles every time. Write the file only when its content changes (WriteLinesToFile with WriteOnlyWhenDifferent="true").Inputs/Outputs that don't match reality: an output the target never writes, so it always looks stale, or a missing input, so a real change is ignored.Generated files should also be added to @(FileWrites) so dotnet clean removes them, and to @(Compile) if they are source files.
| Option | Use when |
|---|---|
Built-in tasks (Copy, MakeDir, Delete, WriteLinesToFile, ReadLinesFromFile, DownloadFile) |
Almost always; they are cross-platform and log properly |
Exec |
Running a CLI tool; check ExitCode and pass EchoOff / ConsoleToMSBuild as needed |
Inline task (RoslynCodeTaskFactory) |
A few lines of C# logic, kept in a .targets file |
Compiled task (a class deriving from Microsoft.Build.Utilities.Task) |
Reusable logic shipped in a NuGet package |
Task outputs flow back with <Output TaskParameter="..." PropertyName="..." /> or ItemName="...".
dotnet build builds projects in parallel (-m) and reuses MSBuild worker nodes between builds. dotnet build-server shutdown stops them if they hold stale state or locked files.-graph builds the whole project graph up front, which schedules large solutions better.Directory.Build.targets runs in every project. A 2-second Exec costs 2 seconds times the project count..targets can run arbitrary commands through Exec and inline tasks. Building a repository is running its code, so be careful with untrusted repos.-p: properties in CI logs; they appear in binary logs and detailed output. Use environment variables or secret stores and mark them as secret in the pipeline.| Question | Tool |
|---|---|
| What is the final value of a property or item? | dotnet msbuild -getProperty:OutputPath -getItem:Compile |
| Which files were imported, in what order? | dotnet msbuild -preprocess:pp.xml |
| Which targets ran, why, and how long did they take? | dotnet build -bl, then the MSBuild Structured Log Viewer |
| Why did a target run when nothing changed? | Search the binary log for the target; it shows which input was newer than which output |
| Run a single target | dotnet msbuild -t:PrintFiles |
MSBuild ships with each SDK. Expect continued work on the terminal logger, build performance and binary logs. The core model of properties, items, targets and tasks doesn't change. Keep production builds on the .NET 10 SDK until you've tested an upgrade.
PrintFiles and WriteBuildStamp targets from the code example to a console project and run dotnet build.dotnet build -v:d again and find the "Skipping target" lines.dotnet msbuild -t:PrintFiles -p:Configuration=Release and see the global property win.dotnet msbuild -getProperty:OutputPath -getItem:Compile and compare with what you expected.-bl, open the log, find CoreCompile and its duration.Inputs/Outputs from WriteBuildStamp and see it run every time; then make it write a timestamp and see whether it triggers recompiles (it shouldn't, because the file isn't a Compile input).<Message Text="%(Compile.RelativeDir)" /> inside a target and watch batching run the task once per folder.<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BuildStampFile>$(IntermediateOutputPath)build-stamp.txt</BuildStampFile>
</PropertyGroup>
<!-- Uses items and metadata: prints every compiled file and its folder -->
<Target Name="PrintFiles" BeforeTargets="CoreCompile">
<Message Importance="high" Text="Compiling %(Compile.Identity) from %(Compile.RelativeDir)" />
</Target>
<!-- Incremental: only runs when the project file changes -->
<Target Name="WriteBuildStamp" AfterTargets="Build"
Inputs="$(MSBuildProjectFullPath)" Outputs="$(BuildStampFile)">
<WriteLinesToFile File="$(BuildStampFile)" Lines="$(MSBuildProjectName) $(Configuration) $(TargetFramework)" Overwrite="true" />
</Target>
</Project>
dotnet build -v:n
dotnet msbuild -getProperty:Configuration -getProperty:OutputPath -getItem:Compile
dotnet build -bl && echo "open msbuild.binlog in the MSBuild Structured Log Viewer"
Mistake: setting a property inside a target and expecting earlier conditions to see it. Conditions on property groups, items and imports are evaluated before any target runs, so they never see values that targets set. Instead: set values that conditions depend on during evaluation (in props files or the project), and use target conditions for anything decided at execution time.
Mistake: writing custom targets without Inputs and Outputs.
The target runs on every build, and if it touches a file that other targets read, everything downstream rebuilds too.
Instead: declare Inputs and Outputs on every target that produces files, and use item transforms so MSBuild can do partial builds.
Mistake: Inputs and Outputs that don't match what the target really does.
An output the target never writes makes it look permanently stale; a missing input means a real change is skipped and the build goes stale.
Instead: list exactly the files the target reads as Inputs and exactly the files it writes as Outputs, and check the decision in a binary log.
Mistake: always rewriting a generated file that is a compile input.
The file's timestamp changes on every build, so CoreCompile recompiles every project every time.
Instead: write generated files only when the content changes, for example WriteLinesToFile with WriteOnlyWhenDifferent="true".
Mistake: forgetting to add generated files to FileWrites.
dotnet clean and IncrementalClean don't know about them, so stale files survive and can be picked up by later builds.
Instead: add every file a custom target writes to @(FileWrites), and keep generated files under $(IntermediateOutputPath).
Mistake: overriding SDK targets such as AfterBuild in shared files.
Only one definition of a target name wins, so two files that both define AfterBuild silently replace each other.
Instead: create uniquely named targets and attach them with BeforeTargets or AfterTargets.
Mistake: confusing $(...), @(...) and %(...).
$(...) reads a property (one string), @(...) reads an item list, and %(...) reads item metadata and triggers batching. Mixing them gives empty values or tasks that run once per item unexpectedly.
Instead: use $ for single values, @ for lists, and % only when you want per-item or per-group behaviour.
Mistake: using Exec for work a built-in task already does.
Shell commands differ between Windows and Linux, don't log structured output, and aren't incremental.
Instead: use Copy, MakeDir, Delete, WriteLinesToFile and other built-in tasks, and keep Exec for real external tools.
Mistake: passing secrets as -p: properties.
Global properties appear in detailed logs and binary logs, which are often uploaded as CI artefacts.
Instead: pass secrets through environment variables or the pipeline's secret store, and treat binary logs as sensitive.
Mistake: debugging builds by reading console output.
The console hides evaluation details and target decisions, so people guess and add more overrides.
Instead: use -getProperty / -getItem for quick checks and a binary log (-bl) with the Structured Log Viewer for anything more.
MSBuild rewards knowing a few rules well: evaluation before execution, properties before items, nearest-wins imports, and incremental targets. A senior engineer keeps custom build logic rare, small and incremental, puts it in Directory.Build.targets where everyone can find it, and reaches for a binary log the moment a build behaves strangely.
Incrementality in practice. When a target declares Inputs and Outputs, MSBuild compares timestamps before running it: if every output is newer than every input, the target is skipped ("Skipping target ... because all output files are up-to-date"). If the item lists map one-to-one, MSBuild can run the target for just the changed items (partial build). Getting this right is the single biggest lever on build time for custom logic.
Conditional execution. A Condition on a <Target> is evaluated when the target is about to run, not at evaluation time, so a target can be gated on configuration or on a property an earlier target set (for example Condition="'$(Configuration)' == 'Release'"). When the condition is false the target and its DependsOnTargets don't run.
Performance and team practice. Every custom target runs in every project that imports it, so a 2-second Exec in Directory.Build.targets costs 2 seconds times the number of projects. Seniors measure with a binary log before and after, scope targets to the projects that need them, and review build changes with the same care as code. That keeps build times flat as a repo grows, which matters more to a team's productivity than almost any code-level optimisation.
Directory.Build.props and Directory.Build.targets are ordinary MSBuild files that the SDK imports automatically into every project below them. For each project, MSBuild searches upwards and uses the nearest one it finds. .props is imported early, before the project's own content, so it's for default properties and items: target framework, Nullable, analysers, package metadata. Projects can still override those values. .targets is imported late, after the project, so it's for targets and logic that need final values. Only the nearest file is picked up. To layer them, the inner file imports the outer one with GetPathOfFileAbove. I debug them with dotnet msbuild -preprocess or a binary log.
First I get evidence instead of guessing. dotnet build -bl writes a binary log with every property, item, target, task and timing, and I open it in the MSBuild Structured Log Viewer. For the slow build I look at the timeline and the target and task summaries. That shows whether time goes on restore, compilation, analysers or a custom target. For the broken incremental build, I build twice and look at why targets ran the second time. If CoreCompile runs again, one of its inputs changed. Usually a custom target rewrites a generated file on every build, or something uses CopyToOutputDirectory="Always". Other culprits are globs over huge folders and slow analysers, which -p:ReportAnalyzer=true measures. I fix the cause with Inputs/Outputs, by writing files only when their content changes, and by narrowing globs. Then I add a CI check that times a no-change rebuild.
A configuration is a named set of build settings. Debug turns optimisation off and defines the DEBUG and TRACE symbols, so the debugger can show every variable and step line by line. Release turns optimisation on and defines RELEASE and TRACE instead of DEBUG. Both produce PDBs by default. Behaviour can differ for three reasons. Code under #if DEBUG, and calls such as Debug.Assert, are removed from Release. The optimising compiler and JIT inline and reorder code and end variable lifetimes earlier, which changes timing and when objects are garbage collected. And race conditions hidden by slow Debug code start to show. dotnet build defaults to Debug, while dotnet publish and dotnet pack default to Release, so you should test the build you ship.
Last updated 1 Nov 2026.