Incremental Builds and Batching

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.

Two mechanisms decide how often MSBuild runs a piece of work. Incremental builds skip a target whose outputs are newer than its inputs, or run it for just the inputs that changed. Batching runs a task, or a whole target, once per distinct value of some item metadata. Both are driven by attributes on the target (Inputs, Outputs) and by %(…) references, and both explain behaviours that otherwise look arbitrary: why a "nothing changed" build is fast or slow, and why one Message printed three lines. The examples run on MSBuild 18.9 (.NET SDK 10.0.402).

The up-to-date check

A target with Inputs and Outputs is compared file by file before its tasks run:

flowchart TB T["Target with Inputs and Outputs is about to run"] --> MAP{"Outputs a one-to-one transform of Inputs?"} MAP -- yes --> CMP1{"Which inputs have a missing or older output?"} CMP1 -- none --> SKIP["Skip: all output files are up-to-date"] CMP1 -- some --> PART["Run partially: only for those inputs"] CMP1 -- all --> FULL["Run the target for all inputs"] MAP -- no --> CMP2{"Any output missing, or any input newer than any output?"} CMP2 -- yes --> FULL CMP2 -- no --> SKIP SKIP --> INF["Output inference: item and property groups in the target are still applied"] PART --> INF

Without Inputs/Outputs a target runs every time. That is correct for cheap targets, but a target that copies, generates or calls a tool without them makes every build do the work again — and, if it touches files the compiler reads, makes CoreCompile run again too.

A code generator that rebuilds only what changed

The Ledgerline journals are CSV files; this console project turns each one into a generated C# file with a constant naming it. Outputs is a transform of Inputs, so MSBuild can map each journal to its generated file:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <!-- IntermediateOutputPath is not defined yet here (Sdk.targets sets it after the project body);
         BaseIntermediateOutputPath (obj/) is, because Sdk.props sets it before. -->
    <GeneratedJournalDir>$(BaseIntermediateOutputPath)generated/journals/</GeneratedJournalDir>
  </PropertyGroup>

  <ItemGroup>
    <LedgerJournal Include="journals/*.ledger.csv" />
  </ItemGroup>

  <!-- One output per input: MSBuild can run the target for just the inputs that changed. -->
  <Target Name="GenerateJournalClasses"
          BeforeTargets="CoreCompile"
          Inputs="@(LedgerJournal)"
          Outputs="@(LedgerJournal->'$(GeneratedJournalDir)%(Filename).g.cs')">
    <MakeDir Directories="$(GeneratedJournalDir)" />
    <!-- Task batching: %(Filename) runs WriteLinesToFile once per journal. -->
    <WriteLinesToFile File="$(GeneratedJournalDir)%(LedgerJournal.Filename).g.cs"
                      Lines="// generated from %(LedgerJournal.Identity);public static partial class Journals { public const string $([System.String]::Copy('%(LedgerJournal.Filename)').Replace('.', '_')) = &quot;%(LedgerJournal.Identity)&quot;%3B }"
                      Overwrite="true" />
    <Message Importance="high" Text="Generated $(GeneratedJournalDir)%(LedgerJournal.Filename).g.cs" />
  </Target>

  <!-- Separate target without Inputs/Outputs: always adds the generated files, whether or not generation ran. -->
  <Target Name="AddJournalClasses" BeforeTargets="CoreCompile" AfterTargets="GenerateJournalClasses">
    <ItemGroup>
      <Compile Include="@(LedgerJournal->'$(GeneratedJournalDir)%(Filename).g.cs')" />
      <FileWrites Include="@(LedgerJournal->'$(GeneratedJournalDir)%(Filename).g.cs')" />
    </ItemGroup>
  </Target>
</Project>

with Program.cs printing the two constants. First build, a change to one journal, and a build with no change:

dotnet build -tl:off -v:m
dotnet run --no-build
touch journals/jan.ledger.csv && dotnet build -tl:off -v:d | grep GenerateJournalClasses
dotnet build -tl:off -v:d | grep Skipping
  Generated obj/generated/journals/feb.ledger.g.cs
  Generated obj/generated/journals/jan.ledger.g.cs
  Journals -> ~/journals/bin/Debug/net10.0/Journals.dll
journals/jan.ledger.csv journals/feb.ledger.csv
       Building target "GenerateJournalClasses" partially, because some output files are out of date with respect to their input files.
         Generated obj/generated/journals/jan.ledger.g.cs
       Skipping target "GenerateJournalClasses" because all output files are up-to-date with respect to the input files.
       Skipping target "CoreCompile" because all output files are up-to-date with respect to the input files.

Design points:

  • Generation and registration are separate targets. GenerateJournalClasses may be skipped or run partially; AddJournalClasses has no Inputs/Outputs, so the generated files are always added to Compile — otherwise a skipped generator would leave the compiler without them.

  • FileWrites registers the generated files, so dotnet clean deletes them and IncrementalClean keeps them.

  • The output folder is under obj/, so the SDK’s default Compile glob does not pick the files up a second time.

The first version of this project got that last point wrong in a very common way. It defined <GeneratedJournalDir>$(IntermediateOutputPath)journals/</GeneratedJournalDir> in the project body, where IntermediateOutputPath is not yet defined — Sdk.targets sets it after the project body — so the files were written into the source folder:

ls journals
dotnet msbuild -getProperty:GeneratedJournalDir -getProperty:IntermediateOutputPath -getProperty:BaseIntermediateOutputPath
feb.ledger.csv
feb.ledger.g.cs
jan.ledger.csv
jan.ledger.g.cs
{
  "Properties": {
    "GeneratedJournalDir": "journals/",
    "IntermediateOutputPath": "obj/Debug/net10.0/",
    "BaseIntermediateOutputPath": "obj/"
  }
}

-getProperty shows IntermediateOutputPath with its final value, which is exactly why the bug is easy to miss. BaseIntermediateOutputPath is set in Sdk.props, before the project body, so it is safe there.

Output inference

When a target is skipped as up to date, MSBuild still applies the property and item groups inside it ("as if the target ran"), so later targets see the same items either way. Task outputs are inferred only when the Output element’s TaskParameter is one of the task’s inputs. That is why a generator target should create its items with an ItemGroup rather than collect them from a task output — or, as above, leave registration to a separate target. To find out whether a target really ran, the documented trick is a CreateProperty with TaskParameter="ValueSetByTask", which is set only on a real run.

Batching

A %(…) reference splits a list into batches by the distinct values of that metadata. Where it appears decides what is repeated:

  • in a task (an attribute or child of the task, including the implicit tasks that PropertyGroup and ItemGroup are inside targets) → task batching: the task runs once per batch;

  • in the target’s Outputs (or Inputs) → target batching: the whole target runs once per batch, each batch with its own copy of properties and items.

One project with all the cases:

<Project>
  <ItemGroup>
    <Account Include="1000-Cash" Kind="asset" Balance="380" />
    <Account Include="1200-Bank" Kind="asset" Balance="900" />
    <Account Include="3000-Equity" Kind="equity" Balance="-500" />
    <Account Include="6000-Rent" Kind="expense" Balance="120" />
  </ItemGroup>

  <!-- Task batching: the Message task runs once per distinct %(Kind). -->
  <Target Name="TaskBatching">
    <Message Importance="high" Text="%(Account.Kind): @(Account)" />
  </Target>

  <!-- Target batching: the whole target runs once per distinct %(Kind). -->
  <Target Name="TargetBatching" Outputs="%(Account.Kind)">
    <PropertyGroup>
      <KindCount>@(Account->Count())</KindCount>
    </PropertyGroup>
    <Message Importance="high" Text="batch %(Account.Kind): $(KindCount) account(s)" />
  </Target>

  <!-- Self-referencing metadata: per item outside targets, batched (and surprising) inside targets. -->
  <ItemGroup>
    <JournalOutside Include="2026/jan.ledger.csv" Short="%(Filename)%(Extension)" />
    <JournalOutside Include="2026/feb.ledger.csv" Short="%(Filename)%(Extension)" />
    <JournalOutside Include="2026/mar.ledger.csv" Short="%(Filename)%(Extension)" />
  </ItemGroup>

  <Target Name="SelfReferencing">
    <ItemGroup>
      <JournalInside Include="2026/jan.ledger.csv" Short="%(Filename)%(Extension)" />
      <JournalInside Include="2026/feb.ledger.csv" Short="%(Filename)%(Extension)" />
      <JournalInside Include="2026/mar.ledger.csv" Short="%(Filename)%(Extension)" />
    </ItemGroup>
    <Message Importance="high" Text="outside: @(JournalOutside->'%(Short)')" />
    <Message Importance="high" Text="inside:  @(JournalInside) -> [@(JournalInside->'%(Short)')]" />
  </Target>

  <!-- Mutations: with target batching each batch has its own copy of properties. -->
  <Target Name="MutationsTaskBatching">
    <PropertyGroup>
      <CurrentKind>%(Account.Kind)</CurrentKind>
    </PropertyGroup>
    <Message Importance="high" Text="%(Account.Kind) -> CurrentKind=$(CurrentKind)" />
  </Target>
  <Target Name="MutationsTargetBatching" Outputs="%(Account.Kind)">
    <PropertyGroup>
      <CurrentKind>%(Account.Kind)</CurrentKind>
    </PropertyGroup>
    <Message Importance="high" Text="%(Account.Kind) -> CurrentKind=$(CurrentKind)" />
  </Target>
</Project>
for t in TaskBatching TargetBatching SelfReferencing MutationsTaskBatching MutationsTargetBatching; do
  dotnet msbuild batching.proj -t:$t -nologo -v:n
done
== TaskBatching
  asset: 1000-Cash;1200-Bank
  equity: 3000-Equity
  expense: 6000-Rent
== TargetBatching
  batch asset: 2 account(s)
  batch equity: 1 account(s)
  batch expense: 1 account(s)
== SelfReferencing
     1>~/incr/batching.proj(31,51): message : MSB4120: Item 'JournalInside' definition within target references itself via (qualified or unqualified) metadatum 'Filename'. This can lead to unintended expansion and cross-applying of pre-existing items. More info: https://aka.ms/msbuild/metadata-self-ref
         outside: jan.ledger.csv;feb.ledger.csv;mar.ledger.csv
         inside:  2026/jan.ledger.csv;2026/feb.ledger.csv;2026/mar.ledger.csv;2026/mar.ledger.csv -> [;jan.ledger.csv;jan.ledger.csv;feb.ledger.csv]
== MutationsTaskBatching
         asset -> CurrentKind=expense
         equity -> CurrentKind=expense
         expense -> CurrentKind=expense
== MutationsTargetBatching
         asset -> CurrentKind=asset
         equity -> CurrentKind=equity
         expense -> CurrentKind=expense

(The == headings are added by the loop; each block is the output of one target, the batched lines abridged from the normal verbosity log.)

  • Task vs target batching. In TaskBatching the Message task ran three times inside one target run; in TargetBatching the target ran three times, and @(Account->Count()) inside it saw only the batch’s items.

  • Mutations. With task batching, the PropertyGroup is an implicit task that batches on its own, so CurrentKind ends with the last batch’s value before Message runs — every line prints expense. With target batching each batch has its own copy and the values line up. The documentation’s StubDirs example shows the same effect.

  • Self-referencing metadata. An item whose metadata references its own well-known metadata (%(Filename)) is evaluated per item outside targets, as expected. Inside a target the same definition batches over the items already defined: the first Short is empty, the others take a previous item’s value, and mar.ledger.csv appears twice. MSBuild reports MSB4120 (as a low-importance message) to point at it. Define such items outside targets, or compute the metadata with an explicit transform.

Common mistakes

  • A target without Inputs/Outputs — runs on every build; if it rewrites files the compiler reads, CoreCompile runs too.

  • Outputs that the target never writes — the target is never up to date.

  • Reading IntermediateOutputPath or OutputPath in the project body — not defined yet; use BaseIntermediateOutputPath or read it inside a target.

  • Adding generated files to Compile only in the generating target — missing when the target is skipped.

  • Forgetting FileWrites — dotnet clean leaves the files behind.

  • Using a property set by a batched PropertyGroup — it holds the last batch’s value.

  • Self-referencing metadata inside targets — MSB4120.