Targets and Build Order

This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its dotnet CLI) and NuGet 7.9, as published in the MSBuild, .NET and NuGet documentation on learn.microsoft.com, which is the reference these pages are written and verified against. Material about .NET 11 / NuGet 8.0 is still in preview and is always flagged as such — never presented as baseline.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The C# Reference bibliography lists the reference material consulted while preparing these pages.

A target is a named list of tasks. A build is a request to run some targets, and MSBuild works out from their attributes which other targets must run, in which order, and which can be skipped. Hooking custom work into the right place of the SDK’s build — before the compiler, after packing, once per target framework or once per project — is a matter of choosing the right attribute and the right anchor target. This page shows the rules running, with MSBuild 18.9 (.NET SDK 10.0.402).

Declaring and choosing targets

<Target Name="…"> declares a target anywhere in the project or its imports; a later declaration with the same name replaces the earlier one completely. Which targets run first:

  1. the targets in the InitialTargets attribute of the Project element (all of them, from every imported file, in import order) — always;

  2. then the targets passed with -t: (-target:); if none, those in DefaultTargets; if none, the first target in the file after imports are expanded. For SDK projects that is Build.

The order algorithm

For each target it is asked to build, MSBuild applies the same steps; a target that has already run in this build (for the same project and global properties) is never run again.

flowchart TB START["Target T requested (InitialTargets, -t:, DefaultTargets, first target, or by another target)"] --> DONE{"T already ran?"} DONE -- yes --> SKIPALL["Nothing to do"] DONE -- no --> COND{"T's Condition true?"} COND -- no --> BEF2["Targets with BeforeTargets=T still run"] BEF2 --> AFT2["Targets with AfterTargets=T still run"] COND -- yes --> DEPS["Run T's DependsOnTargets, in order"] DEPS --> BEF["Run targets that list T in BeforeTargets"] BEF --> INC{"Inputs/Outputs up to date?"} INC -- yes --> SKIP["T skipped (up to date)"] INC -- no --> RUN["Run T's tasks"] SKIP --> AFT["Run targets that list T in AfterTargets"] RUN --> AFT

A demonstration with every attribute (the numbers in the messages are the expected order):

<Project InitialTargets="Init" DefaultTargets="Report">
  <Target Name="Init">
    <Message Importance="high" Text="1 Init (InitialTargets)" />
  </Target>

  <Target Name="ReadJournals">
    <Message Importance="high" Text="3 ReadJournals (DependsOnTargets of Report)" />
  </Target>

  <Target Name="CheckBalance" BeforeTargets="Report">
    <Message Importance="high" Text="4 CheckBalance (BeforeTargets=Report)" />
  </Target>

  <Target Name="Report" DependsOnTargets="Prepare;ReadJournals" Returns="@(ReportFile)">
    <ItemGroup>
      <ReportFile Include="out/trial-balance.json" />
    </ItemGroup>
    <Message Importance="high" Text="5 Report" />
  </Target>

  <Target Name="Prepare">
    <Message Importance="high" Text="2 Prepare (first in DependsOnTargets; runs once even if requested again)" />
  </Target>

  <Target Name="Publish" AfterTargets="Report" DependsOnTargets="Prepare">
    <Message Importance="high" Text="6 Publish (AfterTargets=Report)" />
  </Target>

  <Target Name="Skipped" BeforeTargets="Report" Condition="'$(Audit)' == 'true'" DependsOnTargets="AuditPrep">
    <Message Importance="high" Text="  Skipped ran" />
  </Target>
  <Target Name="AuditPrep">
    <Message Importance="high" Text="  AuditPrep ran" />
  </Target>
</Project>
dotnet msbuild order.proj -nologo -v:m
dotnet msbuild order.proj -nologo -v:m -p:Audit=true
  1 Init (InitialTargets)
  2 Prepare (first in DependsOnTargets; runs once even if requested again)
  3 ReadJournals (DependsOnTargets of Report)
  4 CheckBalance (BeforeTargets=Report)
  5 Report
  6 Publish (AfterTargets=Report)
---
  1 Init (InitialTargets)
  2 Prepare (first in DependsOnTargets; runs once even if requested again)
  3 ReadJournals (DependsOnTargets of Report)
  4 CheckBalance (BeforeTargets=Report)
    AuditPrep ran
    Skipped ran
  5 Report
  6 Publish (AfterTargets=Report)

Publish depends on Prepare too, but Prepare had already run. Skipped and its dependency AuditPrep ran only when its condition was true; when false, its DependsOnTargets were not run either. Two BeforeTargets targets for the same anchor run in declaration order.

Returns, Outputs and -getTargetResult

Returns names the items a target hands back to its caller — the MSBuild task that invoked it (as ResolveProjectReferences does with GetTargetPath), or the command line. If no target in the file has Returns, Outputs is used instead, which mixes two concerns (incremental checks and return values); the SDK sets Returns on its public targets. -getTargetResult (MSBuild 17.8+) builds a target and prints its result and returned items:

dotnet msbuild order.proj -nologo -t:Report -getTargetResult:Report
{
  "TargetResults": {
    "Report": {
      "Result": "Success",
      "Items": [
        {
          "Identity": "out/trial-balance.json",
          "FullPath": "~/targets/out/trial-balance.json",
          …
          "Filename": "trial-balance",
          "Extension": ".json",
          …
        }
      ]
    }
  }
}

Hooking into an SDK build safely

The SDK targets come after the project body (from the implicit Sdk.targets import), so a project cannot redefine BeforeBuild or AfterBuild and expect it to win, and the SDK’s targets call many internal ones whose names start with an underscore and may change. The documented approach: give your target a descriptive name and attach it with BeforeTargets/AfterTargets to a public target such as BeforePublish, CoreCompile, Build or Pack.

The anchor matters more than it looks. Build itself is empty: its work is in its DependsOnTargets (CoreBuild), and the algorithm runs DependsOnTargets before BeforeTargets. A hook written as BeforeTargets="Build" therefore runs after compilation — and, in a multi-targeted project, once per inner build plus once in the outer build. Added to the Ledgerline library (three TFMs):

<Project>
  <Target Name="LedgerlineBeforeBuild" BeforeTargets="Build">
    <Message Importance="high" Text="LedgerlineBeforeBuild: TargetFramework='$(TargetFramework)'" />
  </Target>
</Project>
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Debug/net8.0/Ledgerline.dll
  LedgerlineBeforeBuild: TargetFramework='net8.0'
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Debug/net10.0/Ledgerline.dll
  LedgerlineBeforeBuild: TargetFramework='net10.0'
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Debug/netstandard2.0/Ledgerline.dll
  LedgerlineBeforeBuild: TargetFramework='netstandard2.0'
  LedgerlineBeforeBuild: TargetFramework=''

Four runs, each after its DLL was written. The fix depends on the intent:

<Project>
  <!-- Once per target framework, before the compiler runs. -->
  <Target Name="LedgerlineBeforeCompile" BeforeTargets="CoreCompile">
    <Message Importance="high" Text="LedgerlineBeforeCompile: $(TargetFramework)" />
  </Target>

  <!-- Once per build of the multi-targeted project, before the inner builds start. -->
  <Target Name="LedgerlineOnce" BeforeTargets="DispatchToInnerBuilds">
    <Message Importance="high" Text="LedgerlineOnce: TargetFrameworks='$(TargetFrameworks)'" />
  </Target>
</Project>
  LedgerlineOnce: TargetFrameworks='net10.0;net8.0;netstandard2.0'
  LedgerlineBeforeCompile: netstandard2.0
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Debug/netstandard2.0/Ledgerline.dll
  LedgerlineBeforeCompile: net8.0
  LedgerlineBeforeCompile: net10.0
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Debug/net8.0/Ledgerline.dll
  Ledgerline -> ~/ledgerline/src/Ledgerline/bin/Debug/net10.0/Ledgerline.dll

DispatchToInnerBuilds exists only in the outer build of a multi-targeted project. For a target that must run exactly once whether the project has one TFM or several, the documentation’s recipe combines both: a target with BeforeTargets="DispatchToInnerBuilds" for the multi-targeted case, and a target with BeforeTargets="BeforeBuild" that calls the outer project through the MSBuild task with RemoveProperties="TargetFramework" — MSBuild then runs the "once" target in that outer instance only once, because a target never runs twice for the same project and global properties. Other common anchors:

To run… Hook

before compiling, per TFM (code generation, validation)

BeforeTargets="CoreCompile" — as Ledgerline.Build does

after the output is copied, per TFM

AfterTargets="CopyFilesToOutputDirectory" (or AfterTargets="Build" in a single-TFM project)

before/after publishing

BeforeTargets="BeforePublish", AfterTargets="Publish"

to add files to a package

TargetsForTfmSpecificContentInPackage / TargetsForTfmSpecificBuildOutput (properties listing your targets) — see Props, Targets, Tasks and Analyzers in Packages

after the package is written

AfterTargets="Pack"

The standard targets

Microsoft.Common.CurrentVersion.targets (imported by every SDK project) defines the public targets: Build, Rebuild, Clean, Publish, ResolveReferences, Compile, CopyFilesToOutputDirectory, the empty extension points BeforeBuild, AfterBuild, BeforeCompile, AfterCompile, BeforeClean, AfterClean, BeforePublish, AfterPublish, and the …DependsOn properties (BuildDependsOn, CoreBuildDependsOn, CompileDependsOn) that list each target’s dependencies and can be extended. NuGet adds Restore and Pack, the test platform VSTest and the SDK Publish-related and container targets. dotnet msbuild -targets lists every target a project has.

Asking for one that does not exist:

dotnet msbuild -t:Deploy
~/demo/App.csproj : error MSB4057: The target "Deploy" does not exist in the project.

Common mistakes

  • BeforeTargets="Build" for work that must happen before compiling — it runs after; use CoreCompile.

  • A hook that runs once per TFM plus once in the outer build — condition on $(TargetFramework) or use DispatchToInnerBuilds.

  • Redefining BeforeBuild/AfterBuild in an SDK project — the SDK’s empty definitions come later and win.

  • Hooking underscore-prefixed targets — internal and subject to change.

  • Expecting a target to run twice — each target runs at most once per project instance; call the project again with different global properties if it really must.

  • Outputs used only to return items — use Returns; Outputs turns on incremental checks.