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.

The whole model in one page

Before getting into the XML, separate the layers that one dotnet build command hides:

dotnet CLI
    ├── normally restore packages → obj/project.assets.json
    └── invoke MSBuild
            ├── evaluate the project and all imports
            │       → properties and item lists
            └── execute the requested target graph
                    → tasks such as Csc, Copy, and Exec

The .NET CLI is the user-facing command. MSBuild is the engine. The .csproj, SDK files, NuGet-provided build files, and Directory.Build.* files are inputs to that engine. NuGet restore resolves packages and records their assets for MSBuild to consume (--no-restore skips the implicit restore when those assets are already current). Targets then arrange work, and tasks perform it.

This gives unfamiliar errors somewhere to live:

  • a surprising property or missing file is usually an evaluation problem;
  • a target running in the wrong order is an execution graph problem;
  • a missing package assembly is often a restore or asset-selection problem;
  • a compiler diagnostic comes from the task MSBuild eventually invoked.

The rest of the chapter opens each box in that diagram.

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 an item whose condition was decided during evaluation, even though both look like ordinary XML in the same file:

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

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

<Target Name="EnableExtraAndReport" BeforeTargets="Build">
  <PropertyGroup>
    <IncludeExtra>true</IncludeExtra>
  </PropertyGroup>
  <Message Text="Report inputs: @(ReportInput)" Importance="high" />
</Target>

This looks like it should print Extra.txt: the target sets IncludeExtra to true before its Message task reads @(ReportInput). It prints an empty list. ReportInput's condition was already evaluated against the earlier value false, before any target ran. Changing the property during execution does not rewind evaluation and reconsider the item.

Passing the value early enough produces the intended result:

$ dotnet build -property:IncludeExtra=true
  Report inputs: Extra.txt

The fix is not a different target hook. The real value must come from the command line or an earlier property/import so evaluation can see it when the item is created.

Properties overwrite; items accumulate

Document order affects properties and items differently. A later property assignment normally replaces the earlier value:

<PropertyGroup>
  <ConfigurationName>First</ConfigurationName>
  <ConfigurationName>Second</ConfigurationName>
</PropertyGroup>

$(ConfigurationName) ends as Second. Items form lists, so repeated Include operations append:

<ReportInput Include="one.txt" />
<ReportInput Include="two.txt" />

@(ReportInput) contains both files. Remove subtracts matching items and Update changes metadata on items already present. In an SDK-style project, changing metadata on an implicitly included source usually uses Update, not Include:

<Compile Update="Generated.cs">
  <Visible>false</Visible>
</Compile>

Using Include="Generated.cs" would try to add a second copy of a file the SDK's **/*.cs glob already included, producing the NETSDK1022 duplicate item error.

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.

For a searchable text representation, preprocess the project:

$ dotnet msbuild MyApp.csproj -preprocess:effective.xml

effective.xml expands the imports into one large file, with comments marking where each imported section came from. It answers "which definition did MSBuild evaluate, and in what order?" A binary log answers the different question "what happened while that evaluated project executed?" Search the preprocessed file for a property or target definition; use the binary log for task parameters, timings, and execution-time values.

Modern MSBuild can also query evaluated state without temporary Message targets:

$ dotnet msbuild MyApp.csproj -getProperty:TargetFramework,OutputPath
{
  "Properties": {
    "TargetFramework": "net8.0",
    "OutputPath": "bin/Debug/net8.0/"
  }
}

$ dotnet msbuild MyApp.csproj -getItem:Compile

These commands inspect evaluation. If a property is assigned later inside a target, request that target too or use a binary log to observe its execution-time value.

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"
        BeforeTargets="CoreCompile"
        Inputs="$(MSBuildProjectFile)"
        Outputs="$(IntermediateOutputPath)version.g.cs">
  <WriteLinesToFile File="$(IntermediateOutputPath)version.g.cs"
                     Lines="// $(Version)" Overwrite="true" />
</Target>

BeforeTargets="CoreCompile" connects the custom target to the build graph. Without that hook—or a DependsOnTargets edge or an explicit -target:GenerateVersionFile request—declaring the target alone would not cause it to run. With the hook in place, two builds using dotnet build -v:detailed show the incremental decision:

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 makes the targets in A that consume B's output wait for the required targets in B. It does not mean every part of A waits for the whole of B: evaluation and work without that dependency may overlap, especially in a graph build. Nor does it specify ordering within either project's own target graph. This is 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 gathers a deployment-shaped directory: the application, its .deps.json/.runtimeconfig.json, content files, and selected dependency assets. By default this is framework-dependent—the destination still needs a compatible .NET runtime:

$ dotnet publish -c Release
# bin/Release/net8.0/publish/

A self-contained publish explicitly includes a runtime for one runtime identifier, making the output larger and platform-specific:

$ dotnet publish -c Release -r linux-x64 --self-contained true
# bin/Release/net8.0/linux-x64/publish/

Single-file, trimming, ReadyToRun, and native AOT are additional publish choices, not optimizations every publish automatically applies. build optimizes for the development loop; publish computes the output intended for deployment. Treating those outputs as interchangeable—copying a random bin/Release directory to production, for example—is the common mistake.

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.

A practical debugging sequence

When a build is surprising, start with the cheapest observation that can answer the question:

# What value or item list did evaluation produce?
dotnet msbuild MyApp.csproj -getProperty:OutputPath
dotnet msbuild MyApp.csproj -getItem:Compile

# Which imported file assigned or defined it?
dotnet msbuild MyApp.csproj -preprocess:effective.xml

# Which targets and tasks actually ran?
dotnet build MyApp.csproj -bl:build.binlog

# Why was an incremental target run or skipped?
dotnet build MyApp.csproj -verbosity:detailed

Open build.binlog in MSBuild Structured Log Viewer and search for the property, item, target, task, or error code. The viewer reconstructs the evaluation and execution trees and is normally more useful than increasing console verbosity to diagnostic, whose interleaved output becomes difficult to follow in a parallel build.

For a clean-room comparison, remove bin/ and obj/ and rebuild—but do not make that the permanent remedy. If cleaning fixes the build, the useful next question is which target failed to declare or refresh the intermediate state that made cleaning necessary.

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" />

A half-migrated project that retains Version="13.0.3" on PackageReference does not silently win: restore reports NU1008 and tells you to move the version to PackageVersion. An intentional exception uses VersionOverride:

<PackageReference Include="Newtonsoft.Json"
                  VersionOverride="13.0.4" />

Repositories can disable overrides with <CentralPackageVersionOverrideEnabled>false</CentralPackageVersionOverrideEnabled> when the central pin is meant to be absolute. - 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.