Incremental Builds and Batching
|
This section documents MSBuild 18.9, the .NET SDK 10.0.402 (with its 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:
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('.', '_')) = "%(LedgerJournal.Identity)"%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.
GenerateJournalClassesmay be skipped or run partially;AddJournalClasseshas noInputs/Outputs, so the generated files are always added toCompile— otherwise a skipped generator would leave the compiler without them. -
FileWritesregisters the generated files, sodotnet cleandeletes them andIncrementalCleankeeps them. -
The output folder is under
obj/, so the SDK’s defaultCompileglob 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
PropertyGroupandItemGroupare inside targets) → task batching: the task runs once per batch; -
in the target’s
Outputs(orInputs) → 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
TaskBatchingtheMessagetask ran three times inside one target run; inTargetBatchingthe target ran three times, and@(Account->Count())inside it saw only the batch’s items. -
Mutations. With task batching, the
PropertyGroupis an implicit task that batches on its own, soCurrentKindends with the last batch’s value beforeMessageruns — every line printsexpense. With target batching each batch has its own copy and the values line up. The documentation’sStubDirsexample 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 firstShortis empty, the others take a previous item’s value, andmar.ledger.csvappears twice. MSBuild reportsMSB4120(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,CoreCompileruns too. -
Outputsthat the target never writes — the target is never up to date. -
Reading
IntermediateOutputPathorOutputPathin the project body — not defined yet; useBaseIntermediateOutputPathor read it inside a target. -
Adding generated files to
Compileonly in the generating target — missing when the target is skipped. -
Forgetting
FileWrites—dotnet cleanleaves the files behind. -
Using a property set by a batched
PropertyGroup— it holds the last batch’s value. -
Self-referencing metadata inside targets —
MSB4120.