BrandGhost
Incremental Source Generators in C#: The Right Way to Build Them

Incremental Source Generators in C#: The Right Way to Build Them

Source generation has been one of the most impactful features Roslyn has added to the .NET ecosystem. Instead of writing repetitive boilerplate by hand, you can have Roslyn generate it at compile time -- zero runtime reflection, zero allocations, and zero maintenance overhead on code that never changes. But there is a right way and a wrong way to build them. The incremental source generator API introduced in .NET 6 is the right way. If you are still writing ISourceGenerator, you are almost certainly making your users' IDEs slower than they need to be -- in a codebase with many attributed types, your users may be paying a performance cost on every keystroke without realizing why.

This guide targets .NET 8, 9, and 10 developers who want to write source generators that perform well in the IDE, cache correctly, and scale as codebases grow. We will cover the pipeline architecture, the building blocks, the equality model, and walk through a complete working example using C# 11+ idioms.

The Problem with ISourceGenerator

The original ISourceGenerator interface shipped with .NET 5. It seemed straightforward: implement Initialize, implement Execute, done. The problem was in how Roslyn integrated it.

Every time you typed a character in the IDE, Roslyn re-ran the entire generator pipeline. Not just the changed file -- the whole thing. Every class declaration, every attribute lookup, every string concatenation to build source text. With a small codebase that might be imperceptible. With a large codebase containing hundreds of types decorated with generator attributes, it became a serious IDE slowdown. The generator's Execute method received a GeneratorExecutionContext containing the full Compilation, and it was expected to walk syntax trees and semantic models fresh every run.

// Example 1: The old ISourceGenerator approach -- DO NOT use this in modern .NET
[Generator]
public class OldStyleGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context)
    {
        // You register a SyntaxReceiver here to filter nodes,
        // but it still runs on EVERY keystroke in the IDE
        context.RegisterForSyntaxNotifications(() => new MySyntaxReceiver());
    }

    public void Execute(GeneratorExecutionContext context)
    {
        // context.Compilation is the FULL compilation -- every call
        // walks the entire syntax tree from scratch. No caching. No
        // incremental diffing. If you have 500 attributed classes,
        // Roslyn re-processes all 500 every single keystroke.
        if (context.SyntaxContextReceiver is not MySyntaxReceiver receiver)
            return;

        foreach (var classSymbol in receiver.Classes)
        {
            var source = GenerateCode(classSymbol);
            context.AddSource($"{classSymbol.Name}.g.cs", source);
        }
    }
}

// The SyntaxReceiver visits EVERY node in EVERY tree on every rebuild
internal class MySyntaxReceiver : ISyntaxContextReceiver
{
    public List<INamedTypeSymbol> Classes { get; } = new();

    public void OnVisitSyntaxNode(GeneratorSyntaxContext context)
    {
        if (context.Node is ClassDeclarationSyntax cds &&
            context.SemanticModel.GetDeclaredSymbol(cds) is INamedTypeSymbol symbol)
        {
            if (symbol.GetAttributes().Any(a => a.AttributeClass?.Name == "GenerateAttribute"))
                Classes.Add(symbol);
        }
    }
}

The ISourceGenerator API is effectively deprecated for new code. The Roslyn team themselves recommend migrating to IIncrementalGenerator. If you are shipping a NuGet package with an ISourceGenerator today, your users are filing bugs about IDE lag -- they just may not have pinpointed the cause yet.

Introducing IIncrementalGenerator

The IIncrementalGenerator interface was introduced in .NET 6 / Roslyn 4.x as the replacement for ISourceGenerator. The fundamental design difference is that instead of receiving the entire compilation on every run and computing everything from scratch, you declare a pipeline -- a graph of transformations -- that Roslyn can cache and replay incrementally.

Roslyn treats each node in this pipeline as a pure function. If the inputs to a node haven't changed since the last run, Roslyn skips it entirely and reuses the cached output. This means that when you type a character in a file that has nothing to do with your generator's attributes, almost nothing in your generator pipeline actually re-executes. The IDE stays responsive. Builds stay fast.

This incremental source generator API is what you should be writing in .NET 8, .NET 9, and .NET 10. For new code targeting the .NET 7+ SDK, there is no reason to reach for ISourceGenerator.

The Incremental Pipeline Architecture

The mental model for an incremental source generator is a data-flow graph. You declare nodes called value providers, connect them through transformations, and register outputs that Roslyn will call only when a provider's value has changed.

Each node in the graph:

  • Receives values from upstream nodes
  • Applies a pure transformation
  • Emits output values that are compared against the cached result using equality
  • Is only re-executed if the input values are not equal to the cached inputs

The pipeline is declared once in Initialize. Roslyn then owns the scheduling. You never see the compilation unless you explicitly ask for it -- and even then, you should only grab the specific pieces you need rather than holding a reference to the whole Compilation object.

Here is the key insight for every incremental source generator: every stage in the pipeline is a cache boundary. If the output of a stage is equal to the cached output, downstream stages are never called. This is why equality is so critical -- the caching only works if your models implement equality correctly.

Key Building Blocks

The incremental source generator API is built around a small set of focused types. Knowing which one to reach for at each stage is what makes the pipeline feel natural rather than awkward.

IncrementalGeneratorInitializationContext

This is the entry point passed to Initialize in any incremental source generator. Through it you access all value providers and register outputs. Its most important members are:

  • SyntaxProvider -- the gateway to filtering the syntax tree
  • CompilationProvider -- gives you the full Compilation (use sparingly)
  • AnalyzerConfigOptionsProvider -- access to .editorconfig and per-file options
  • AdditionalTextsProvider -- access to additional non-code files
  • RegisterSourceOutput -- declare an output driven by a provider
  • RegisterPostInitializationOutput -- register static source that never changes (e.g., your marker attributes)

SyntaxProvider

The SyntaxProvider is how you hook into the syntax tree. It has two important methods:

CreateSyntaxProvider lets you filter by any arbitrary syntax node predicate and then optionally transform with semantic information. It runs the predicate on every node in the tree, so keep it as cheap as possible -- node is ClassDeclarationSyntax style checks only, no string comparisons on names.

ForAttributeWithMetadataName is the performance-optimized path for attribute-driven generators. We will cover this in detail in its own section, because it is the right default for almost every generator you will ever write.

ValueProvider Transformations

Once you have a provider, you shape it through LINQ-style transformations:

  • Select -- map each value to a new value
  • Where -- filter values by a predicate (creates a sparse provider)
  • Collect -- gather all values into an ImmutableArray<T> (use when you need to emit a single file containing all items)
  • Combine -- merge two providers into a provider of tuples (use when you need both a model and a configuration value together)

These transformations are all lazy and composable. Roslyn builds the graph from your declarations and executes nodes on demand.

RegisterSourceOutput vs RegisterImplementationSourceOutput vs RegisterPostInitializationOutput

RegisterSourceOutput is the general-purpose output registration. Roslyn calls your action whenever the provider produces a new (or changed) value. Use this for almost everything.

RegisterImplementationSourceOutput works the same way but tells Roslyn the generated source is only needed for the final binary -- it can be skipped during design-time (IDE) builds. Use this for generated code that is not referenced by user code in any way that affects compilation (rare).

RegisterPostInitializationOutput is for static source that never changes based on user code. Register your marker attributes here. They are added to the compilation before any other generator logic runs, which means your own SyntaxProvider queries can immediately see classes decorated with those attributes.

Caching and Equality

This is the most commonly overlooked requirement for incremental source generator C# authors. Roslyn's caching model is based on value equality. When the pipeline produces a model object, Roslyn compares it to the cached version using .Equals. If they are equal, downstream nodes are skipped.

If your model does not implement IEquatable<T>, Roslyn falls back to object.ReferenceEquals, which means every pipeline stage will see "changed" values on every run -- effectively destroying the incremental benefit.

// Example 2: Proper equality model for incremental generator caching
// IEquatable<T> is REQUIRED for Roslyn's caching to work correctly

internal sealed record ClassModel(
    string Namespace,
    string ClassName,
    ImmutableArray<string> Properties) : IEquatable<ClassModel>
{
    // Standard record equality handles Namespace and ClassName correctly,
    // but ImmutableArray<T> uses reference equality by default.
    // You MUST override Equals to get value-based comparison on the array.
    public bool Equals(ClassModel? other) =>
        other is not null &&
        Namespace == other.Namespace &&
        ClassName == other.ClassName &&
        Properties.SequenceEqual(other.Properties);

    public override bool Equals(object? obj) => Equals(obj as ClassModel);

    // Hashing only Properties.Length is insufficient -- two ClassModel instances
    // with the same property count but different property names would share a hash.
    // Use a content-based hash by aggregating over the elements instead.
    public override int GetHashCode() =>
        HashCode.Combine(Namespace, ClassName, Properties.IsDefaultOrEmpty ? 0 : Properties.Aggregate(0, (h, p) => HashCode.Combine(h, p)));
}

The trap here is ImmutableArray<T>. It is a value type and widely used in generator models because it is allocation-efficient, but its Equals implementation compares by reference internally. Two ImmutableArray<string> instances with identical contents will not be considered equal unless you call SequenceEqual. Always override Equals and GetHashCode on any model that contains an ImmutableArray.

This same requirement applies at every stage of the incremental source generator pipeline. If you use Select to transform a raw syntax node into a model, that model must implement equality. If you use Combine to merge two providers, the tuple values must each implement equality.

This caching discipline is what makes incremental source generators genuinely fast in large codebases. The payoff for a bit of extra equality boilerplate is that the IDE stays responsive and CI builds complete in seconds rather than minutes.

ForAttributeWithMetadataName: The Performance King

If your incremental source generator is attribute-driven -- and most are -- ForAttributeWithMetadataName is the method you should reach for first.

The standard CreateSyntaxProvider approach visits every node in every syntax tree to apply your predicate. Even if your predicate is cheap, this is proportional to the total size of the codebase. A project with 200 files calls your predicate against every class, method, property, and expression in all 200 files.

ForAttributeWithMetadataName is backed by a Roslyn-internal index. Roslyn already maintains an attribute-by-name index as part of its incremental compilation model. When you query this index, Roslyn only calls your predicate and transform delegates for syntax nodes that are actually decorated with the named attribute -- not every node in the project. In large codebases, this can be the difference between an incremental source generator that runs in a few milliseconds and one that runs in hundreds of milliseconds.

Always prefer ForAttributeWithMetadataName when your incremental source generator targets a specific attribute. Only fall back to CreateSyntaxProvider when you need to trigger on syntax structure alone without any attribute involvement. Available since Roslyn 4.3 / .NET 7 SDK -- if you are using the .NET 8+ SDK this is always available.

Building an Incremental Generator Step by Step

Here is a minimal but complete incremental source generator in C# that reads classes decorated with a [Generate] attribute and emits a partial class with a ToDebugString() method.

// Example 3: Minimal IIncrementalGenerator skeleton using C# 11+ idioms

[Generator]
public sealed class MyIncrementalGenerator : IIncrementalGenerator
{
    private const string AttributeSource = """
        namespace MyNamespace;

        [AttributeUsage(AttributeTargets.Class, Inherited = false)]
        public sealed class GenerateAttribute : Attribute { }
        """;

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Step 1: Register the marker attribute as static, unchanging source.
        // This runs once and is cached forever -- RegisterPostInitializationOutput
        // is the correct home for attribute definitions.
        context.RegisterPostInitializationOutput(static ctx =>
            ctx.AddSource("GenerateAttribute.g.cs", AttributeSource));

        // Step 2: Build the incremental pipeline using ForAttributeWithMetadataName.
        // The predicate runs only for nodes decorated with the attribute --
        // NOT for every node in the project.
        var provider = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyNamespace.GenerateAttribute",
                predicate: static (node, _) => node is ClassDeclarationSyntax,
                transform: static (ctx, ct) => GetModel(ctx, ct))
            .Where(static model => model is not null)
            .Select(static (model, _) => model!);

        // Step 3: Register the output. Roslyn calls this only when
        // a model value has changed since the last run.
        context.RegisterSourceOutput(provider, static (spc, model) =>
            spc.AddSource($"{model.ClassName}.g.cs", GenerateCode(model)));
    }

    private static ClassModel? GetModel(
        GeneratorAttributeSyntaxContext ctx,
        CancellationToken ct)
    {
        ct.ThrowIfCancellationRequested();

        if (ctx.TargetSymbol is not INamedTypeSymbol typeSymbol)
            return null;

        var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace
            ? string.Empty
            : typeSymbol.ContainingNamespace.ToDisplayString();

        var properties = typeSymbol.GetMembers()
            .OfType<IPropertySymbol>()
            .Where(p => p.DeclaredAccessibility == Accessibility.Public)
            .Select(p => p.Name)
            .ToImmutableArray();

        return new ClassModel(ns, typeSymbol.Name, properties);
    }

    private static string GenerateCode(ClassModel model)
    {
        var sb = new StringBuilder();

        if (!string.IsNullOrEmpty(model.Namespace))
            sb.AppendLine($"namespace {model.Namespace};").AppendLine();

        sb.AppendLine($"partial class {model.ClassName}");
        sb.AppendLine("{");
        sb.AppendLine("    public string ToDebugString()");
        sb.AppendLine("    {");
        sb.AppendLine("        var sb = new System.Text.StringBuilder();");
        sb.AppendLine("        sb.Append("" + model.ClassName + " { ");");

        foreach (var prop in model.Properties)
            sb.AppendLine($"        sb.Append("{prop} = ").Append({prop}).Append(", ");");

        sb.AppendLine("        sb.Append("}");");
        sb.AppendLine("        return sb.ToString();");
        sb.AppendLine("    }");
        sb.AppendLine("}");

        return sb.ToString();
    }
}

This incremental source generator example follows every best practice: static lambdas (no accidental closure captures), CancellationToken passed to the transform, ForAttributeWithMetadataName instead of CreateSyntaxProvider, and a model that will implement IEquatable<T> as shown in Example 2 above.

Combining Multiple Providers

Real incremental source generators often need to merge information from two different sources -- for example, the list of target classes combined with global options from .editorconfig. The Combine operator handles this by pairing each value from one provider with the latest value from another, re-executing only when either side changes:

// Example 4: Using Combine to merge a SyntaxProvider with a config provider

public void Initialize(IncrementalGeneratorInitializationContext context)
{
    context.RegisterPostInitializationOutput(static ctx =>
        ctx.AddSource("GenerateAttribute.g.cs", AttributeSource));

    // Provider A: target class models
    var classProvider = context.SyntaxProvider
        .ForAttributeWithMetadataName(
            "MyNamespace.GenerateAttribute",
            predicate: static (node, _) => node is ClassDeclarationSyntax,
            transform: static (ctx, ct) => GetModel(ctx, ct))
        .Where(static m => m is not null)
        .Select(static (m, _) => m!);

    // Provider B: global configuration options
    var configProvider = context.AnalyzerConfigOptionsProvider
        .Select(static (options, _) =>
        {
            options.GlobalOptions.TryGetValue(
                "build_property.MyGenerator_EnableNullChecks",
                out var raw);
            return bool.TryParse(raw, out var value) && value;
        });

    // Combine: produces (ClassModel, bool) for each class,
    // re-executes only if either the model OR the config changes
    var combined = classProvider.Combine(configProvider);

    context.RegisterSourceOutput(combined, static (spc, pair) =>
    {
        var (model, enableNullChecks) = pair;
        spc.AddSource($"{model.ClassName}.g.cs", GenerateCode(model, enableNullChecks));
    });
}

Combine pairs every value from the left provider with every value from the right provider -- which works perfectly when the right provider emits a single configuration object. In an incremental source generator, if both sides can emit multiple values, consider using Collect first to gather them into an array before combining.

Performance Tips and Common Mistakes

These are the mistakes that appear in almost every first-pass incremental source generator C# implementation. Knowing them upfront will save you hours of debugging IDE slowdowns.

Mistake 1: Capturing Compilation in transform lambdas.

The Compilation object is enormous. If you capture it in a lambda, Roslyn cannot cache the result efficiently -- any change anywhere in the project will invalidate your entire pipeline.

// Example 5: Compilation capture -- the wrong way vs the correct approach

// ❌ WRONG: Captures the full Compilation in the transform lambda.
// Any change to any file invalidates this cache node. The "incremental"
// benefit is completely lost because Compilation changes on every keystroke.
var badProvider = context.SyntaxProvider
    .ForAttributeWithMetadataName(
        "MyNamespace.GenerateAttribute",
        predicate: static (node, _) => node is ClassDeclarationSyntax,
        transform: (ctx, ct) =>
        {
            // ctx.SemanticModel.Compilation is accessible but holding it
            // in the output model poisons the cache for all downstream nodes
            var compilation = ctx.SemanticModel.Compilation;
            return new BadModel(ctx.TargetSymbol.Name, compilation); // ← WRONG
        });

// ✅ CORRECT: Extract ONLY what you need from the compilation during transform.
// The output model contains only plain data -- strings, booleans, ImmutableArrays.
// Roslyn can cache this model and skip downstream nodes if it hasn't changed.
var goodProvider = context.SyntaxProvider
    .ForAttributeWithMetadataName(
        "MyNamespace.GenerateAttribute",
        predicate: static (node, _) => node is ClassDeclarationSyntax,
        transform: static (ctx, ct) =>
        {
            // Extract only what you need -- no Compilation reference in the model
            var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
            var ns = symbol.ContainingNamespace.ToDisplayString();
            var name = symbol.Name;
            // Return plain data -- fully cacheable
            return new ClassModel(ns, name, ImmutableArray<string>.Empty);
        });

Mistake 2: Non-static lambdas in the pipeline. If your lambda closes over this or any instance field, it captures state that Roslyn cannot reason about. Always use static lambdas in ForAttributeWithMetadataName, Select, Where, and Combine.

Mistake 3: Using Collect prematurely. Collect forces all values to be gathered before any downstream output can run. If you Collect too early in the pipeline, a change to any single class invalidates the entire collected array. Only call Collect at the stage where you actually need all values together -- typically right before RegisterSourceOutput for a "registry" file.

Mistake 4: Skipping CancellationToken checks. The ct parameter passed to your transform delegates is a real cancellation token. For long-running transforms, call ct.ThrowIfCancellationRequested() periodically. Roslyn cancels and restarts generator runs when the user continues typing.

Mistake 5: Missing equality on nested collections. As discussed in the caching section, ImmutableArray<T> requires explicit SequenceEqual in your Equals override. If your model has nested arrays or complex types, each level must implement equality correctly.

Generators also pair naturally with design pattern infrastructure. If you are generating factory registration boilerplate, the factory method pattern gives you a clean model for what that boilerplate should look like. Similarly, if you are auto-generating builder classes, the incremental caching model means your generated builders are only recomputed when the target class actually changes -- not on every keystroke.

Generated decorator classes are another excellent use case: a generator that reads an interface and emits a decorator stub removes an entire category of hand-written boilerplate. The same applies to strategy pattern registrations -- a generator can scan for IStrategy implementations and emit a DI registration file. For plugin-based architectures, incremental generators are what power DI plugin discovery in modern .NET frameworks, and they can also enforce singleton registrations by emitting registration code for types marked with a [Singleton] attribute.

FAQ

The questions below address the practical concerns that come up most often when adopting incremental source generators.

What is the difference between ISourceGenerator and IIncrementalGenerator?

ISourceGenerator re-runs its entire Execute method on every compilation change, including every keystroke in the IDE. IIncrementalGenerator declares a data-flow pipeline that Roslyn caches at each stage. Only the stages whose inputs have changed are re-executed, making IIncrementalGenerator far more efficient for IDE performance. Since .NET 6 / Roslyn 4.x, IIncrementalGenerator is the standard API for new generators. ISourceGenerator is considered deprecated for new code.

Do incremental source generators work in .NET Framework projects?

Incremental source generators are a Roslyn feature, so they work in any project that uses a modern C# compiler -- including .NET Framework projects when using the Microsoft.Net.Compilers.Toolset package. The generator itself targets netstandard2.0 for maximum compatibility. The target project's runtime framework does not affect generator availability.

Why does my incremental source generator not cache correctly?

The most common cause is a missing or incorrect IEquatable<T> implementation on your model. If your model's Equals returns false when the values are logically identical, Roslyn will treat every run as a change and re-execute all downstream nodes. Check that all fields implement value equality -- particularly ImmutableArray<T>, which requires SequenceEqual.

Can I use async code in a source generator transform?

No. All incremental source generator pipeline delegates are synchronous. You should not spin up tasks or use async/await inside generator code. If you need to do I/O (reading additional files, for example), use the AdditionalTextsProvider which exposes file content synchronously.

How do I debug a source generator?

The easiest approach is to add <IsRoslynComponent>true</IsRoslynComponent> to your generator project and use Visual Studio's "Attach to Process" with the Roslyn compiler process. Alternatively, set DOTNET_ROLL_FORWARD=LatestMajor and use Debugger.Launch() in your Initialize method during development. For most logic bugs, writing unit tests using the Microsoft.CodeAnalysis.CSharp.Testing and Microsoft.CodeAnalysis.Analyzers.Testing packages is the most reliable approach -- you can assert exact generated output without needing a full IDE.

How do I emit multiple source files from a single provider?

Each call to spc.AddSource(fileName, sourceText) inside RegisterSourceOutput emits one file. If your provider produces one model per class, you call AddSource once per model and get one file per class. If you need a single file aggregating all classes, use Collect to gather all models into an ImmutableArray<ClassModel> first, then call RegisterSourceOutput on the collected provider and emit one file.

Should I target netstandard2.0 for my generator project?

Yes. Incremental source generator assemblies are loaded by the compiler host, which may be running on different .NET runtimes depending on the build environment. Targeting netstandard2.0 ensures the generator loads correctly in Visual Studio, Rider, the .NET CLI, and Roslyn-based tools. Your generator project can reference Microsoft.CodeAnalysis.CSharp which itself targets netstandard2.0, so there is no friction here.

Conclusion

The incremental source generator API is not just a performance optimization over ISourceGenerator -- it is a fundamentally better programming model. You declare what you want to compute and under what conditions it should be recomputed. Roslyn handles the scheduling, caching, and cancellation. Your generator stays fast whether the codebase has five classes or five thousand.

The key takeaways for incremental source generator development: use IIncrementalGenerator, not ISourceGenerator. Prefer ForAttributeWithMetadataName over CreateSyntaxProvider for attribute-driven generators. Implement IEquatable<T> on every model. Never capture Compilation in a transform lambda. Use static lambdas throughout. And register your marker attributes via RegisterPostInitializationOutput so they are available before your pipeline queries run.

The initial learning curve is real -- the pipeline model takes a session or two to internalize. But once it clicks, incremental source generators are one of the most powerful tools in the .NET developer's kit: compile-time code generation with IDE performance that scales to enterprise codebases.

How C# Source Generators Work: The Roslyn Compilation Pipeline Explained

Learn exactly how C# source generators work inside the Roslyn compilation pipeline. Understand the two-phase compilation model, syntax providers, and incremental execution.

C# Source Generators: A Complete Guide to Compile-Time Code Generation

Master C# source generators with this complete guide. Learn how compile-time code generation works in .NET 10, why it beats reflection, and how to get started.

How to Create Your First C# Source Generator (Step-by-Step)

Learn how to create a C# source generator with .NET 10. Step-by-step guide covering project setup, IIncrementalGenerator implementation, and packaging.

An error has occurred. This application may no longer respond until reloaded. Reload