Shared build configuration: how MSBuild finds and imports Directory.Build.props early and Directory.Build.targets late, nearest-file-wins and chaining with GetPathOfFileAbove, what to put in each, conditions per project type, and debugging with preprocess and binary logs.
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.
"
Directory.Build.propsandDirectory.Build.targetsare ordinary MSBuild files that the SDK imports automatically into every project below them. For each project, MSBuild searches upwards from the project's folder and uses the nearest one. The.propsfile comes in early in the SDK's import order, before the project's own content, so it's for defaults the project can still override: target framework,Nullable,LangVersion, analysers and package metadata. Package versions I'd keep separately inDirectory.Packages.props.The
.targetsfile comes in late, after the project and most SDK targets, so it's for custom targets and any logic that depends on values the project sets, likeOutputTypeorIsPackable. That's because MSBuild evaluates all properties first, then items, then runs targets, so a condition in.propscan't see a value the project hasn't set yet. To vary settings by project type I use conditions such as'$(IsTestProject)' == 'true'or$(MSBuildProjectName).Parent files aren't merged: only the nearest file is imported, so a nested file has to import the outer one with
GetPathOfFileAbove. The mistake I see is addingtests/Directory.Build.propswithout that import, and every test project silently losesTreatWarningsAsErrors. When a value surprises me, I check it withdotnet msbuild -preprocessor a binary log with-blrather than guessing.Because one line changes every project, I treat these files as shared infrastructure: a small documented root file, CODEOWNERS protection, and a CI job that prints the key properties per project so reviewers see exactly what moved. That's what lets it scale to governance across many teams."
The SDK's Microsoft.Common.props looks for Directory.Build.props starting in the project's folder and walking up to the drive root. It imports the first one found. Microsoft.Common.targets does the same for Directory.Build.targets. Nothing else is imported automatically.
Sdk.props (includes Directory.Build.props)Sdk.targets (includes Directory.Build.targets)So a .props value is a default the project can override, and a .targets file sees the project's final values.
| Directory.Build.props | Directory.Build.targets |
|---|---|
TargetFramework, LangVersion, Nullable, ImplicitUsings |
Custom targets (BeforeTargets="Build"), for example a version stamp or a licence check |
TreatWarningsAsErrors, AnalysisLevel, EnforceCodeStyleInBuild |
Settings that depend on values the project sets, such as OutputType or IsPackable |
Package metadata: Authors, Company, RepositoryUrl |
Items that must be added after the project's own items |
Analyser PackageReferences (with CPM, GlobalPackageReference in Directory.Packages.props) |
|
ArtifactsPath / output layout |
Rule of thumb: if it reads a value that the project might set, it belongs in .targets.
Only the nearest file is imported, so a tests/Directory.Build.props hides the root one unless it imports it:
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
In a 60-project repo, every project file repeated the same ten properties, and they had drifted: some projects had nullable warnings off, two still targeted net8.0. Moving them into a root Directory.Build.props made the project files a few lines each, and a TFM upgrade to net10.0 became one line plus fixes. Later, someone added tests/Directory.Build.props without importing the root one, and every test project silently lost TreatWarningsAsErrors. A binary log showed which file was imported, and the GetPathOfFileAbove import fixed it.
Checks practical MSBuild knowledge for real repos.
Green flags: explains search order and early/late import; knows nearest-file-wins and GetPathOfFileAbove; puts the right things in each file; debugs with -preprocess or -bl.
Red flags: copies the same properties into every project; thinks all parent files are merged automatically; never looked at a binary log.
Orders.Tests.csproj (<Project Sdk="Microsoft.NET.Sdk">)
Sdk.props
Microsoft.Common.props
search upwards from the project folder for Directory.Build.props
import the FIRST one found <-- early
Directory.Packages.props (if Central Package Management is used)
SDK default properties and item globs
---------------------------------------------------------------
the project's own content (can override anything above)
---------------------------------------------------------------
Sdk.targets
Microsoft.CSharp.targets -> Microsoft.Common.targets
... most build targets defined ...
search upwards for Directory.Build.targets
import the FIRST one found <-- late
remaining SDK targets
The search starts in the project's folder ($(MSBuildProjectDirectory)) and walks up to the drive root. It stops at the first match. Parent files are never merged automatically.
Most surprises with these files come from MSBuild's evaluation passes, which run in this order across all imported files:
| Pass | What happens | Consequence |
|---|---|---|
| 1. Environment and global properties | Environment variables and -p: values are loaded |
-p:Configuration=Release overrides anything a file sets, unless the file lists it in TreatAsLocalProperty |
| 2. Properties and imports | Every <PropertyGroup> in import order; <Import> elements are followed |
A property set later wins. A condition can only see properties set earlier |
| 3. Item definitions | <ItemDefinitionGroup> defaults for item metadata |
|
| 4. Items | Every <ItemGroup> in import order, including SDK globs |
Items can use any property, because all properties are already final |
| 5. Targets | Targets are only collected here and run later, during the build | Inside a target, properties and items reflect everything evaluated, plus changes made by earlier targets |
So in Directory.Build.props:
<!-- WRONG: OutputType is set in the project file, which hasn't been evaluated yet -->
<PropertyGroup Condition="'$(OutputType)' == 'Exe'">
<SelfContained>true</SelfContained>
</PropertyGroup>
The condition sees an empty OutputType (or the SDK default, Library). The same block in Directory.Build.targets sees the project's value.
Put it in Directory.Build.props when... |
Put it in Directory.Build.targets when... |
|---|---|
It is a default the project may override (TargetFramework, Nullable, LangVersion) |
It depends on a value the project sets (OutputType, IsPackable, IsTestProject set by hand) |
It affects paths or behaviour the SDK computes early (UseArtifactsOutput, ArtifactsPath, BaseIntermediateOutputPath) |
It defines or hooks a target (BeforeTargets="Build", AfterTargets="Pack") |
It is shared metadata (Company, Authors, RepositoryUrl, PackageLicenseExpression) |
It adds items that must come after the project's items |
It switches on repo-wide quality settings (TreatWarningsAsErrors, AnalysisLevel, EnforceCodeStyleInBuild) |
It enforces a rule on final values, such as failing the build if Nullable was turned off |
IsTestProject is a useful case: test SDK packages set it in their own .props or .targets, so whether it is visible in your .props depends on the package. Test with -getProperty before you rely on it, or condition on the folder or project name instead.
repo/
Directory.Build.props <-- root defaults
src/
Orders.Api/Orders.Api.csproj -> uses repo/Directory.Build.props
tests/
Directory.Build.props <-- must import the root file explicitly
Orders.Tests/Orders.Tests.csproj
samples/
Directory.Build.props <-- may deliberately NOT import the root
<!-- tests/Directory.Build.props -->
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
The search for the parent must start one level up ($(MSBuildThisFileDirectory)../), otherwise it finds the file itself. Use MSBuildThisFileDirectory (the folder of the file being evaluated) rather than MSBuildProjectDirectory (the folder of the project) inside shared files.
| File or property | Purpose |
|---|---|
Directory.Packages.props |
Central package versions; imported after Directory.Build.props, so it can use its properties |
Directory.Solution.props / Directory.Solution.targets |
Imported when MSBuild builds a solution (the metaproject), not individual projects |
Directory.Build.rsp |
Default command-line switches for msbuild / dotnet build in that folder tree, for example -bl or -m |
ImportDirectoryBuildProps=false |
Turns the search off, for example for a vendored third-party project inside the repo |
DirectoryBuildPropsPath |
Points at a specific file instead of searching |
ImportDirectoryBuildTargets=false / DirectoryBuildTargetsPath |
The same switches for .targets |
Every imported file is an input to the build. Editing Directory.Build.props invalidates the up-to-date checks of every project below it, so the next build recompiles everything. Keep the root file stable, and don't write timestamps, Git hashes or other per-build values into properties that feed compilation on developer machines; limit those to CI with a condition such as '$(ContinuousIntegrationBuild)' == 'true'.
| Question | Command |
|---|---|
| What is the final value of a property? | dotnet msbuild src/Orders.Api -getProperty:TargetFramework -getProperty:Nullable |
| Which files were imported, in what order? | dotnet msbuild -preprocess:pp.xml src/Orders.Api, then search for Directory.Build |
| Where was a property set and overridden? | dotnet build -bl, open msbuild.binlog in the MSBuild Structured Log Viewer and search for the property name |
| What items exist after evaluation? | dotnet msbuild -getItem:Compile -getItem:PackageReference |
| Did a target run, and why? | The binary log's target view shows each target, its inputs and whether it was skipped |
After editing these files, IDEs may show cached values. Reload the project or restart the IDE before concluding a change didn't work.
Because one line can change every project, add a cheap check that asserts the important values:
for proj in $(git ls-files '*.csproj'); do
twae=$(dotnet msbuild "$proj" -getProperty:TreatWarningsAsErrors)
nullable=$(dotnet msbuild "$proj" -getProperty:Nullable)
if [ "$twae" != "true" ] || [ "$nullable" != "enable" ]; then
echo "::error::$proj has TreatWarningsAsErrors=$twae Nullable=$nullable"
failed=1
fi
done
exit ${failed:-0}
Printing the same values before and after a change to the shared files also gives reviewers a clear diff of what moved.
Directory.Build.targets runs on every developer machine and CI agent. Review target changes as you would review a script.Directory.Build.*, Directory.Packages.props, global.json and .editorconfig with CODEOWNERS so platform owners review every change.Directory.Build.targets in a parent folder (even outside the repo) can run code during a build.The import model is stable in MSBuild and is not expected to change for .NET 11. New SDK properties appear with each release, so read the release notes before adding them to the root file, and keep production on the .NET 10 SDK until you've tested an upgrade.
src/App and tests/App.Tests.Directory.Build.props with <Nullable>enable</Nullable> and <TreatWarningsAsErrors>true</TreatWarningsAsErrors>.dotnet msbuild tests/App.Tests -getProperty:TreatWarningsAsErrors.tests/Directory.Build.props with only <IsPackable>false</IsPackable> and run the same command. The value is now empty.GetPathOfFileAbove import and run it again..props, add a property group conditioned on '$(OutputType)' == 'Exe', check it with -getProperty on a console app, then move it to Directory.Build.targets and check again.-bl, open the log in the Structured Log Viewer and search for TreatWarningsAsErrors to see every place it was set.<!-- repo/Directory.Build.props -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<Company>Contoso Pty Ltd</Company>
<Authors>Contoso Platform Team</Authors>
<RepositoryUrl>https://github.com/contoso/orders</RepositoryUrl>
</PropertyGroup>
</Project>
<!-- repo/Directory.Build.targets: logic that needs final project values -->
<Project>
<!-- Only packable libraries need symbols and package validation -->
<PropertyGroup Condition="'$(IsPackable)' == 'true' and '$(OutputType)' == 'Library'">
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<EnablePackageValidation>true</EnablePackageValidation>
</PropertyGroup>
<Target Name="PrintBuildInfo" BeforeTargets="CoreCompile" Condition="'$(ContinuousIntegrationBuild)' == 'true'">
<Message Importance="high" Text="$(MSBuildProjectName): $(TargetFramework) $(Configuration)" />
</Target>
</Project>
<!-- repo/tests/Directory.Build.props: layer on top of the root file -->
<Project>
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);CA1707</NoWarn> <!-- underscores in test method names -->
</PropertyGroup>
</Project>
Mistake: adding a nested Directory.Build.props without importing the parent.
MSBuild imports only the nearest file, so every project below the new file silently loses the repo-wide settings, such as TreatWarningsAsErrors or Nullable.
Instead: start every nested file with an <Import> of the parent using $([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../')), unless you deliberately want to cut it off.
Mistake: conditioning .props settings on values the project sets.
.props is evaluated before the project's content, so OutputType, IsPackable or a hand-set property is still empty (or the SDK default) at that point, and the condition never matches.
Instead: move logic that depends on project values into Directory.Build.targets, which is evaluated after the project.
Mistake: putting everything in Directory.Build.targets.
Values set late override the project's own settings, so a project can no longer opt out of a default.
Instead: put defaults in .props so projects can override them, and use .targets only for targets and logic that need final values.
Mistake: putting package versions in Directory.Build.props.
Shared PackageReference items with versions there are hard to override and mix two concerns, and they conflict with Central Package Management.
Instead: keep build properties in Directory.Build.props and package versions in Directory.Packages.props.
Mistake: using $(MSBuildProjectDirectory) to find files next to the shared file.
It's the folder of the project being built, so paths break for projects at different depths.
Instead: use $(MSBuildThisFileDirectory) inside Directory.Build.* files for paths relative to the file itself.
Mistake: adding heavy custom targets that run on every build.
Every developer pays for them on every inner-loop build, and targets without Inputs and Outputs are never skipped.
Instead: prefer properties over targets, give targets Inputs/Outputs so they can be skipped, and limit CI-only work with Condition="'$(ContinuousIntegrationBuild)' == 'true'".
Mistake: writing per-build values such as timestamps into shared properties. Every build sees a changed input, so incremental builds recompile every project. Instead: compute build numbers and commit hashes only in CI, and keep developer builds deterministic.
Mistake: trusting what the IDE shows straight after editing these files.
IDEs cache evaluation results, so you can see old values and conclude a change didn't work.
Instead: check with dotnet msbuild -getProperty:<Name> from the command line, and reload the project in the IDE.
Mistake: debugging by guesswork.
Adding more properties to fix a value you don't understand leaves layers of overrides that nobody can follow.
Instead: use dotnet msbuild -preprocess to see the import order, and a binary log (-bl) to see exactly where each property was set.
Mistake: no owner for the shared build files.
Anyone can change settings for every project in one line, and a bad change isn't noticed until much later.
Instead: protect Directory.Build.* with CODEOWNERS, describe the effect in each pull request, and add a CI check that asserts key properties for every project.
Directory.Build files are the main lever for consistency across a repo, so a senior engineer treats them as a product: a small, documented root file with the organisation's defaults, explicit layering for tests and samples, logic in .targets only when it needs final values, and CODEOWNERS protection. Changes go through pull requests with a short explanation of the effect on every project, ideally with a CI job that prints the key properties per project before and after, so a reviewer can see exactly what moved and a bad change can be reverted in one commit. They also know the failure modes (nearest-file-wins, evaluation order) and debug with binary logs rather than guesswork. Combined with Central Package Management, global.json and .editorconfig, these files let a TFM upgrade or a new analyser rule roll out in one reviewed pull request.
I'd add a Directory.Packages.props at the repo root that sets ManagePackageVersionsCentrally to true and lists one PackageVersion per package. Then I'd strip the Version from every PackageReference, or restore fails with NU1008. First I inventory the current versions with dotnet list package and choose one version for each package, usually the highest that the tests pass with. Analysers go in as GlobalPackageReference items. Transitive pinning lets the central file also patch vulnerable transitive packages. VersionOverride covers genuine exceptions, but only with review. The pitfalls: nested Directory.Packages.props files hiding the root one, NU1507 when there are several feeds without source mapping, and upgrades that become "big bang" unless Dependabot or Renovate update the central file in small batches.
I'd treat the build as a product, with a contract that lives in files at the repo root:
global.json for the SDK.Directory.Build.props/.targets for framework, nullable, analysers and output layout.Directory.Packages.props with transitive pinning for versions.nuget.config with source mapping, plus lock files..editorconfig for style and analyser severities.Project files stay tiny. A small platform group owns those files through CODEOWNERS, and there's a documented exception process for overrides and suppressions. CI uses shared templates that run exactly what developers run locally. The warning policy ratchets stricter over time. SDK, framework and package upgrades arrive as automated, grouped pull requests. I'd measure it all (build time, warnings, drift, time to patch) so governance helps teams instead of just blocking them.
An SDK-style project begins with <Project Sdk="Microsoft.NET.Sdk">. That one attribute pulls in all the standard build logic, so the file only holds what's specific to your project: the target framework, the output type, a few settings and its references. Source files are picked up automatically by globbing, so you don't list each .cs file. Packages are PackageReference items. Old-style .NET Framework project files listed every file, imported targets by hand and used packages.config. That made them long, noisy in merges and tied to Visual Studio on Windows.
How to tell it (STAR)
**Situation:** In our [60-project] repo at [company], test projects had quietly stopped failing on warnings after someone added a nested Directory.Build.props. **Task:** I wanted to find out why and make sure repo-wide settings couldn't be dropped silently again. **Action:** Comparing final property values across projects showed the test projects only imported the nested file, because MSBuild uses the nearest one. I chained the parent file with GetPathOfFileAbove, cleaned up [N] warnings, and added a CI check that asserts key properties for every project. **Result:** Settings became consistent across all projects again, and the check has since caught [N] similar mistakes before merge.
Last updated 1 Nov 2026.