Incremental Source Generators Done Right: Pipeline Patterns That Scale
This is part 2 of 4 in the Incremental Source Generators Done Right series. Part 1 established the equality contract: every value in the pipeline is cached and compared, and if your model type does not implement value equality, your generator re-runs on every keystroke no matter how clever the rest of your code is. If you have not read it, start there — nothing in this post works without it.
This part is about the pipeline itself. You can have perfectly equatable models and still ship a generator that hammers the IDE, because the shape of the pipeline decides how much work happens before the cache even gets a chance to help. And as the predecessor article on hidden generator costs showed, that work is multiplied by every build, every target framework, and every keystroke a developer types for the lifetime of the project.
The rules are few. The ways to violate them are many. Let us walk through them.
ForAttributeWithMetadataName: The Modern Entry Point
Almost every generator I have written or reviewed follows the same trigger pattern: the user decorates a type or member with a marker attribute, and the generator produces code for it. Roslyn has a dedicated, heavily optimized API for exactly this pattern: SyntaxProvider.ForAttributeWithMetadataName, available since Microsoft.CodeAnalysis 4.3.
First, the marker attribute itself. Do not ask users to define it — deliver it from the generator via RegisterPostInitializationOutput, which runs exactly once per generator instance, before any user code is processed:
[Generator]
public sealed class AutoToStringGenerator : IIncrementalGenerator
{
private const string AttributeSource = """
// <auto-generated/>
namespace AutoToString;
[System.AttributeUsage(System.AttributeTargets.Class)]
internal sealed class AutoToStringAttribute : System.Attribute
{
}
""";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(static ctx =>
ctx.AddSource("AutoToStringAttribute.g.cs",
SourceText.From(AttributeSource, Encoding.UTF8)));
}
}
Then the pipeline entry point, with the predicate and transform both static:
IncrementalValuesProvider<ClassModel?> classes = context.SyntaxProvider
.ForAttributeWithMetadataName(
fullyQualifiedMetadataName: "AutoToString.AutoToStringAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => ClassModel.Create(ctx, ct));
Why not the older CreateSyntaxProvider? Because CreateSyntaxProvider calls your predicate for every syntax node — the driver caches per syntax tree, so that means every node in the project on every host start, and every node in the edited file on every keystroke. ForAttributeWithMetadataName instead uses Roslyn’s internal attribute index: it pre-filters to nodes that syntactically carry an attribute whose name could match, then verifies the full metadata name semantically. Published benchmarks put this at roughly 99% fewer nodes visited compared to a naive syntax provider. Same result, two orders of magnitude less work — and that work sits on the typing hot path in the IDE, so the difference is directly perceptible.
The static keyword on both lambdas is not decoration. A lambda that captures state drags that state into the cached pipeline node, and captured state is almost never value-equatable — a captured Compilation, a captured list, a captured this. static makes the compiler reject captures outright. Write every pipeline lambda as static and let the compiler enforce what code review would otherwise miss.
Filter Early, Transform Late
Every operator boundary in the pipeline — every .Select, .Where, .Combine — is a cache node. The framework memoizes the output of each node and only re-executes downstream nodes when the input actually changed (by value equality, see part 1). This gives you a simple layering rule: do the cheapest checks earliest, and defer the most expensive work to the latest possible node.
The predicate should be syntax-only and dumb. No semantic model, no symbol lookups, just shape checks:
predicate: static (node, _) =>
node is ClassDeclarationSyntax { Members.Count: > 0 } cls
&& cls.Modifiers.Any(SyntaxKind.PartialKeyword),
The transform is where you touch the semantic model — once — and immediately flatten everything into your equatable record. Extract the minimum you need to generate code, and nothing else. Return null for cases you cannot handle, and filter them in a separate Where node:
IncrementalValuesProvider<ClassModel> models = context.SyntaxProvider
.ForAttributeWithMetadataName(
"AutoToString.AutoToStringAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) =>
{
if (ctx.TargetSymbol is not INamedTypeSymbol symbol)
{
return null;
}
// Flatten to primitives immediately. The symbol must NOT
// survive past this method — it is reference-equal only
// and roots the entire compilation in memory.
return new ClassModel(
Namespace: symbol.ContainingNamespace.ToDisplayString(),
Name: symbol.Name,
Properties: new EquatableArray<string>(
symbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public)
.Select(p => p.Name)
.ToImmutableArray()));
})
.Where(static model => model is not null)!;
And the most common misplacement of all: string building. The generated source text does not belong in the transform. Building it there means you re-allocate the full output string on every re-run of that node, and worse, the (potentially large) string becomes the cached value that gets compared. Source text belongs in RegisterSourceOutput, the terminal node — it only executes when the model actually changed, which is precisely when you want to rebuild the string:
context.RegisterSourceOutput(models, static (spc, model) =>
{
// Correct place for string building: this only runs on real changes.
string source = ToStringEmitter.Emit(model);
spc.AddSource($"{model.Name}.AutoToString.g.cs",
SourceText.From(source, Encoding.UTF8));
});
Predicate filters syntax. Transform extracts data. Output builds strings. Three layers, three costs, in ascending order — and the cache sits between each of them.
Combining Providers Without Killing the Cache
Sooner or later your generator needs information beyond the annotated node — the assembly name, the language version, an MSBuild property. That is what Combine is for, and it is where most real-world generators quietly destroy their incrementality.
Here is the classic mistake:
// BROKEN: do not do this.
var withCompilation = models.Combine(context.CompilationProvider);
context.RegisterSourceOutput(withCompilation, static (spc, pair) =>
{
var (model, compilation) = pair;
string asm = compilation.AssemblyName ?? "Unknown";
// ... generate using asm ...
});
It compiles. It works. And it re-runs the source output for every model on every keystroke, because a Compilation is a new instance after every edit — that is how Roslyn’s immutable model works. Combining with context.CompilationProvider directly means your terminal node’s input changes whenever anything anywhere in the project changes. You have re-implemented ISourceGenerator with extra steps.
The fix is mechanical: Select the specific values you need out of the compilation before the Combine, so the intermediate node produces a small equatable value that only changes when those values change:
// FIXED: extract only what you need, then combine.
IncrementalValueProvider<(string AssemblyName, bool SupportsRawStrings)> settings =
context.CompilationProvider.Select(static (compilation, _) =>
(
AssemblyName: compilation.AssemblyName ?? "Unknown",
SupportsRawStrings: compilation is CSharpCompilation
{
LanguageVersion: >= LanguageVersion.CSharp11
}
));
var withSettings = models.Combine(settings);
The Select node still executes on every edit — that is unavoidable, its input is the compilation. But it is trivially cheap, and its output is a value tuple of a string and a bool, which compares by value. As long as the assembly name and language version do not change (they do not, between keystrokes), everything downstream of the Combine stays cached. One Select in the right place is the difference between a generator that idles and one that burns a core while you type.
The same pattern applies to context.AnalyzerConfigOptionsProvider when you read MSBuild properties: select the property values out first, combine second.
AdditionalTexts Done Right
Some generators are driven by files rather than attributes — a JSON manifest, a .resx, a config file. Those arrive via context.AdditionalTextsProvider, and the same layering rules apply: filter on the cheap property (the path), then transform the content into an equatable model.
IncrementalValuesProvider<EndpointManifest?> manifests = context.AdditionalTextsProvider
.Where(static file =>
file.Path.EndsWith("endpoints.json", StringComparison.OrdinalIgnoreCase))
.Select(static (file, ct) =>
{
string? content = file.GetText(ct)?.ToString();
return content is null ? null : EndpointManifest.Parse(content);
})
.Where(static manifest => manifest is not null);
Two details matter here. First, always pass the CancellationToken to GetText — the IDE cancels stale pipeline runs constantly, and a transform that ignores cancellation makes typing latency worse for no benefit. Second, be honest about what is and is not cached. The driver compares AdditionalText instances, and a touched file produces a new instance — so the Select re-runs, GetText and Parse execute again. What protects everything downstream is the output of the Select: if EndpointManifest.Parse returns an equatable model and the content did not actually change, the model compares equal to the cached one and nothing after this node re-runs. The caching boundary sits behind your parse, not in front of it — which after part 1 is exactly where your equatable model earns its keep. (If the parse itself is expensive enough to hurt, SourceText.GetContentHash gives you a cheap way to short-circuit manually, but for a config file that is rarely worth the ceremony.)
Collect and the Batching Trap
Sometimes you need all models at once — to generate a single registration class, a module initializer, a combined lookup table. That is .Collect(), which turns an IncrementalValuesProvider<T> (many values) into an IncrementalValueProvider<ImmutableArray<T>> (one batched value).
And there it is again: ImmutableArray<T>. As covered at length in part 1, ImmutableArray<T> has no value equality — two arrays with identical contents are not equal. So a Collect node produces a value that never compares equal to its previous version once any item changes, and, depending on what happens upstream, may hand you a fresh array instance even when no item changed. Every node downstream of a bare Collect is effectively uncached.
You have two idiomatic fixes. Either wrap the batch into the same EquatableArray<T> you already use inside your models:
IncrementalValueProvider<EquatableArray<ClassModel>> allModels = models
.Collect()
.Select(static (array, _) => new EquatableArray<ClassModel>(array))
.WithTrackingName("AllModels");
Or keep the ImmutableArray<T> and attach an explicit comparer to the node with WithComparer:
IncrementalValueProvider<ImmutableArray<ClassModel>> allModels = models
.Collect()
.WithComparer(new ImmutableArrayValueComparer<ClassModel>())
.WithTrackingName("AllModels");
ImmutableArrayValueComparer<T> is not a Roslyn API — it is a ten-line helper you write yourself, an IEqualityComparer<ImmutableArray<T>> that walks both arrays and compares element-wise (the generators in CommunityToolkit.Mvvm ship exactly this shape):
internal sealed class ImmutableArrayValueComparer<T>
: IEqualityComparer<ImmutableArray<T>>
{
public bool Equals(ImmutableArray<T> x, ImmutableArray<T> y)
=> x.SequenceEqual(y);
public int GetHashCode(ImmutableArray<T> obj)
{
var hash = new HashCode();
foreach (T item in obj)
{
hash.Add(item);
}
return hash.ToHashCode();
}
}
I prefer the first: one equality mechanism everywhere, nothing to forget. You will also notice WithTrackingName sprinkled in. It does nothing at runtime — it names the pipeline node so that a test host running the GeneratorDriver with step tracking enabled can find the node in GeneratorRunResult.TrackedSteps (reached via GeneratorDriverRunResult.Results) and assert its IncrementalStepRunReason is Cached or Unchanged. That is the entire subject of part 3, so start naming your nodes now.
A Complete Worked Generator
Here is everything above assembled into one small but honest generator: [AutoToString] on a partial class generates a ToString override listing all public properties.
[Generator]
public sealed class AutoToStringGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(static ctx =>
ctx.AddSource("AutoToStringAttribute.g.cs",
SourceText.From(AttributeSource, Encoding.UTF8)));
IncrementalValuesProvider<ClassModel> models = context.SyntaxProvider
.ForAttributeWithMetadataName(
"AutoToString.AutoToStringAttribute",
predicate: static (node, _) =>
node is ClassDeclarationSyntax cls
&& cls.Modifiers.Any(SyntaxKind.PartialKeyword),
transform: static (ctx, ct) =>
{
ct.ThrowIfCancellationRequested();
if (ctx.TargetSymbol is not INamedTypeSymbol symbol)
{
return null;
}
return new ClassModel(
Namespace: symbol.ContainingNamespace.IsGlobalNamespace
? null
: symbol.ContainingNamespace.ToDisplayString(),
Name: symbol.Name,
Properties: new EquatableArray<string>(
symbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(static p =>
p.DeclaredAccessibility == Accessibility.Public
&& !p.IsStatic)
.Select(static p => p.Name)
.ToImmutableArray()));
})
.Where(static model => model is not null)
.WithTrackingName("Models")!;
context.RegisterSourceOutput(models, static (spc, model) =>
{
var builder = new StringBuilder();
builder.AppendLine("// <auto-generated/>");
builder.AppendLine("#nullable enable");
builder.AppendLine();
if (model.Namespace is not null)
{
builder.AppendLine($"namespace {model.Namespace};");
builder.AppendLine();
}
builder.AppendLine($"partial class {model.Name}");
builder.AppendLine("{");
builder.AppendLine(" public override string ToString() =>");
builder.Append($" $\"{model.Name} {{{{ ");
builder.Append(string.Join(", ",
model.Properties.Select(static p => $"{p} = {{{p}}}")));
builder.AppendLine(" }}\";");
builder.AppendLine("}");
string hintName = model.Namespace is null
? $"{model.Name}.AutoToString.g.cs"
: $"{model.Namespace}.{model.Name}.AutoToString.g.cs";
spc.AddSource(hintName,
SourceText.From(builder.ToString(), Encoding.UTF8));
});
}
}
The supporting model, doing exactly what part 1 demands:
internal sealed record ClassModel(
string? Namespace,
string Name,
EquatableArray<string> Properties);
Trace the layers one more time: the attribute index pre-filters, the predicate confirms shape syntactically, the transform touches symbols once and emits a flat record, Where drops the failures, and the StringBuilder work lives exclusively in RegisterSourceOutput where it only runs when a ClassModel genuinely changed. The hintName includes the namespace so two classes with the same name in different namespaces do not collide — AddSource throws on duplicate hint names, and that failure mode always surfaces in someone else’s codebase, never your test project.
The Rules, In One Place
Everything in this post compresses to five lines:
- Enter through
ForAttributeWithMetadataName, deliver the attribute viaRegisterPostInitializationOutput. - Every pipeline lambda is
static; the compiler is your capture police. - Predicate checks syntax, transform flattens to equatable records,
RegisterSourceOutputbuilds strings. - Never
CombinewithCompilationProviderraw —Selectthe values you need out of it first. CollectproducesImmutableArray<T>; wrap it or attach a comparer before anything consumes it.
Follow these and your generator will be incremental on the day you write it. Whether it stays incremental after six months of pull requests is a different question — one that only a test can answer.
Because none of this counts until a test proves the cache hits.

Comments