Mastering Incremental Source Generators in C#: A Complete Guide with Example

In the Previous blog, I discussed source generators. .NET has introduced an improved version of a source generator, called Incremental Source Generator. The new type generates code faster and reduces the code, improving readability. In this post, I will introduce the new type of source generator and compare it to the previous one. Later, I will provide some best practices to follow for using the incremental source generator at its best.

What is a Source Generator?

The source generator is a Roslyn compiler feature that allows developers to generate source code during the compilation process. You can reduce repetitive, error-prone code with the help of source generators, achieving a faster pace and automatic development.

What is an Incremental Source Generator?

Incremental source generator was introduced in .NET 6 and is a high-performant replacement of .NET's traditional source generator. It cuts the performance overhead of the preceding feature by caching the result, avoiding unnecessary, expensive duplicate compilation and code generation.

Setting up the Consumer project

Let's create a JSON serializer in the source generator and an incremental source generator to understand each of them practically. You can achieve such features with reflection as well. Reflection is easier to implement. However, it works at runtime and is slower than a source generator. Source generator outclasses reflection because it adds files at compile time. While the incremental source generator puts the cherry on top.

Step 1: Create the console consumer project

Run the following command to create the project:

dotnet new console -n ComparativeSourceGenerators

Step 2: install packages

To compare the performance of both generators, we need BenchmarkDotNet. Install it like this:

dotnet add package BenchmarkDotNet

Step 3: Create models

Let's create a simple model for a blog post:

public class Blog
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public string Author { get; set; }
    public DateTime PublishedOn { get; set; }
    public int Views { get; set; }
    public bool IsPublished { get; set; }
    public string[] Tags { get; set; }
}

And a course model:

public class Course
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Instructor { get; set; }
    public int DurationHours { get; set; }
    public double Rating { get; set; }
    public bool IsActive { get; set; }
    public string Description { get; set; }
}

Step 4: Create a JSON serializer with reflection

We aim to compare and observe the general performance difference between the source generator and the incremental source generator. However, to give you a clear picture of a source generator, I will serialize the same objects with a reflection-based serializer. So, the following is the code for that class:

using System.Reflection;
using System.Text;

namespace ComparativeSourceGenerators;

public static class ReflectionJsonSerializer
{
    public static string ToJsonReflection<T>(this T obj)
    {
        var sb = new StringBuilder();
        sb.Append("{");

        var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);

        for (int i = 0; i < props.Length; i++)
        {
            var prop = props[i];
            var value = prop.GetValue(obj);
            var comma = i < props.Length - 1 ? "," : "";
            sb.Append($"\"{prop.Name}\": \"{value}\"{comma}");
        }

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

Create a Source generator

So first, let's create a source generator. We have already discussed the source generator in detail.

Step 1: Create a project

dotnet new classlib -n ESourceGenerator

Step 2: Set the library project as a Source generator

Change the csproj into the following:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>12</LangVersion>
        <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
        <OutputItemType>Analyzer</OutputItemType>
        <IncludeBuildOutput>false</IncludeBuildOutput>
        <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>

    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="4.14.0"/>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
    </ItemGroup>

</Project>

<TargetFramework> sets the target to netstandard2.0 for the source generator. <LangVersion> specifies the language version to C# 12. Setting true in <EmitCompilerGeneratedFiles> allows the developer to inspect the generated code. At the same time, the Analyzer value in <OutputItemType> marks the output DLL of this project as an Analyzer, turning the library into a Roslyn analyzer/generator. <CompilerGeneratedFilesOutputPath> identifies the directory Generated as the output place for the generated code. Besides, you can see referencing of Microsoft.CodeAnalysis.Analyzers and Microsoft.CodeAnalysis.CSharp, which diagnoses generator code by adding Roslyn analyzer rules and adding Roslyn C# compiler APIs, respectively.

Step 3: Define the serializer

using System;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace ESourceGenerator;

[Generator]
public class CustomSerializationGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context) { }

    public void Execute(GeneratorExecutionContext context)
    {
        var serializableAttr = context.Compilation.GetTypeByMetadataName("System.SerializableAttribute");
        if (serializableAttr == null) return;

        foreach (var syntaxTree in context.Compilation.SyntaxTrees)
        {
            var semanticModel = context.Compilation.GetSemanticModel(syntaxTree);
            var classDeclarations = syntaxTree.GetRoot().DescendantNodes()
                .OfType<Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax>();

            foreach (var classDecl in classDeclarations)
            {
                var classSymbol = semanticModel.GetDeclaredSymbol(classDecl) as INamedTypeSymbol;
                if (classSymbol == null) continue;

                if (!classSymbol.GetAttributes().Any(attr =>
                    SymbolEqualityComparer.Default.Equals(attr.AttributeClass, serializableAttr)))
                    continue;

                var ns = classSymbol.ContainingNamespace.ToDisplayString();
                var className = classSymbol.Name;
                var props = classSymbol.GetMembers()
                    .OfType<IPropertySymbol>()
                    .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
                    .ToList();

                var sb = new StringBuilder();
                sb.AppendLine("// <auto-generated>");
                sb.AppendLine($"//{DateTime.Now:yyyy-MM-dd HH:mm:ss}");
                sb.AppendLine($"namespace {ns}.Generated");
                sb.AppendLine("{");
                sb.AppendLine($"  public static class {className}Extensions");
                sb.AppendLine("  {");
                sb.AppendLine($"    public static string ToJsonSourceGen(this {className} obj)");
                sb.AppendLine("    {");
                sb.AppendLine("      var sb = new System.Text.StringBuilder();");
                sb.AppendLine("      sb.Append(\"{\");");

                for (int i = 0; i < props.Count; i++)
                {
                    var prop = props[i];
                    var comma = i < props.Count - 1 ? "," : "";
                    sb.AppendLine($"      sb.Append(\"\\\"{prop.Name}\\\": \\\"\" + obj.{prop.Name} + \"\\\"{comma}\");");
                }

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

                context.AddSource($"{className}Extensions.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
            }
        }
    }
}

Step 4: Build the project

Simply build the project by the IDE or the terminal with the command

dotnet build

Create an Incremental Source generator

Adding the last player in our marathon by adding an incremental source generator project.

Step 1: Create Project

In the console run this:

dotnet new classlib -n ESourceIncGenerator

Step 2: Configure the library as an analyzer

The project file should look like below:

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>12</LangVersion>
        <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
        <OutputItemType>Analyzer</OutputItemType>
        <IncludeBuildOutput>false</IncludeBuildOutput>
        <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="4.14.0"/>
        <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
    </ItemGroup>
</Project>

Step 3: Define the serializer

using System;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace ESourceIncGenerator;

[Generator]
public class CustomSerializationIncrementalGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Step 1: Find all class declarations with [Serializable]
        var classDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (s, _) => IsCandidate(s), // quick filter
                transform: static (ctx, _) => GetSemanticTarget(ctx)) // get symbol
            .Where(static m => m is not null)!;

        // Step 2: Generate code for each matched class
        context.RegisterSourceOutput(classDeclarations, static (spc, classSymbol) =>
        {
            GenerateSerializer(spc, classSymbol!);
        });
    }

    private static bool IsCandidate(SyntaxNode node) =>
        node is ClassDeclarationSyntax cds && cds.AttributeLists.Count > 0;

    private static INamedTypeSymbol? GetSemanticTarget(GeneratorSyntaxContext context)
    {
        var classDecl = (ClassDeclarationSyntax)context.Node;
        var symbol = context.SemanticModel.GetDeclaredSymbol(classDecl) as INamedTypeSymbol;
        if (symbol == null) return null;

        var serializableAttr = context.SemanticModel.Compilation
            .GetTypeByMetadataName("System.SerializableAttribute");

        if (serializableAttr == null) return null;

        // Only pick classes with [Serializable]
        return symbol.GetAttributes().Any(a =>
            SymbolEqualityComparer.Default.Equals(a.AttributeClass, serializableAttr))
            ? symbol
            : null;
    }

    private static void GenerateSerializer(SourceProductionContext context, INamedTypeSymbol classSymbol)
    {
        var ns = classSymbol.ContainingNamespace.ToDisplayString();
        var className = classSymbol.Name;

        var props = classSymbol.GetMembers()
            .OfType<IPropertySymbol>()
            .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
            .ToList();

        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated>");
        sb.AppendLine($"//{DateTime.Now:yyyy-MM-dd HH:mm:ss}");
        sb.AppendLine($"namespace {ns}.Generated");
        sb.AppendLine("{");
        sb.AppendLine($"  public static class {className}IncExtensions");
        sb.AppendLine("  {");
        sb.AppendLine($"    public static string ToJsonIncremental(this {className} obj)");
        sb.AppendLine("    {");
        sb.AppendLine("      var sb = new System.Text.StringBuilder();");
        sb.AppendLine("      sb.Append(\"{\");");

        for (int i = 0; i < props.Count; i++)
        {
            var prop = props[i];
            var comma = i < props.Count - 1 ? "," : "";
            sb.AppendLine($"      sb.Append(\"\\\"{prop.Name}\\\": \\\"\" + obj.{prop.Name} + \"\\\"{comma}\");");
        }

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

        context.AddSource($"{className}Extensions.Incremental.g.cs", SourceText.From(sb.ToString(), Encoding.UTF8));
    }
}

IIncrementalGenerator is the difference that implements the class with a new Roslyn incremental generator, unlike the counterpart that does with ISourceGenerator. The Initialize method is the entry point Roslyn calls when setting up your generator.

The snippet:

var classDeclarations = context.SyntaxProvider
    .CreateSyntaxProvider(
        predicate: static (s, _) => IsCandidate(s), // quick filter
        transform: static (ctx, _) => GetSemanticTarget(ctx)) // get symbol
    .Where(static m => m is not null)!;

efficiently finds the candidate class. The predicate runs on every syntax node, filtering candidates with the method IsCandidate if it's a class with attributes. IsCandidate checks if the node is a ClassDeclarationSyntax in the tree with at least one attribute. While transform runs semantic analysis on candidates to confirm validity. The method GetSemanticTarget returns INamedTypeSymbol if the candidate class has [Serializable]. The context.SyntaxProvider sets an efficient pipeline by reacting only when class declarations change.

The following code:

context.RegisterSourceOutput(classDeclarations, static (spc, classSymbol) =>
{
    GenerateSerializer(spc, classSymbol!);
});

calls GenerateSerializer indicating that Roslyn is to generate code for all the candidates. While the spc (SourceProductionContext) indicates the context to emit new code.

Finally, the GenerateSerializer method generates code as a string, similar to how we do in source generators. In our implementation, only public properties will be serialized as in the code.

var props = classSymbol.GetMembers()
    .OfType<IPropertySymbol>()
    .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
    .ToList();

Step 4: Build the project

Simply build the project by the IDE or the console with the command:

dotnet build

Consume the generators

Now importing generators into the consumer project ComparativeSourceGenerators.

Step 1: Import the generator analysers

Simply add the build libraries to the csproj:

<ItemGroup>
  <Analyzer Include="..\ESourceGenerator\bin\Debug\netstandard2.0\ESourceGenerator.dll" />
  <Analyzer Include="..\ESourceIncGenerator\bin\Debug\netstandard2.0\ESourceIncGenerator.dll" />
</ItemGroup>

Once successfully imported, you can view source generators in the dependencies

Upon Expanding

And the generated file looks like this:

Step 2: Decorate models with the Serializable attribute

Since we have programmed generators to apply to classes with the Serializable attribute, we must decorate our models to leverage these generators. Any model that misses cannot be serialized by generators.

[Serializable]
public class Blog
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public string Author { get; set; }
    public DateTime PublishedOn { get; set; }
    public int Views { get; set; }
    public bool IsPublished { get; set; }
    public string[] Tags { get; set; }
}
[Serializable]
public class Course
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Instructor { get; set; }
    public int DurationHours { get; set; }
    public double Rating { get; set; }
    public bool IsActive { get; set; }
    public string Description { get; set; }
}

Step 3: Prepared benchmarking class

This step will set up benchmarking for all 3 serialization methods, ie, reflection, source generator, and incremental source generator.

using BenchmarkDotNet.Attributes;
using ComparativeSourceGenerators.Models;
using ComparativeSourceGenerators.Models.Generated;

namespace ComparativeSourceGenerators;

[MemoryDiagnoser] // Track memory allocations
public class SerializationBenchmarks
{
    private readonly Blog _blog;
    private readonly Course _course;

    public SerializationBenchmarks()
    {
        _blog = new Blog
        {
            Id = Guid.NewGuid(),
            Title = "Elmah.io Blog",
            Author = "Hamza",
            PublishedOn = DateTime.UtcNow,
            Views = 12345,
            IsPublished = true,
            Tags = new[] { "C#", ".NET", "SourceGenerators" }
        };

        _course = new Course
        {
            Id = Guid.NewGuid(),
            Name = "Intro to Source Generators",
            Instructor = "Ali",
            DurationHours = 12,
            Rating = 4.7,
            IsActive = true,
            Description = "A comprehensive course on C# source generators"
        };
    }

    private const int Iterations = 10000;

    [Benchmark]
    public string Reflection_Blog()
    {
        string result = "";
        for (int i = 0; i < Iterations; i++)
        {
            result = _blog.ToJsonReflection(); // Reflection
        }
        return result;
    }

    [Benchmark]
    public string SourceGen_Blog()
    {
        string result = "";
        for (int i = 0; i < Iterations; i++)
        {
            result = _blog.ToJsonSourceGen(); // Source Generator
        }
        return result;
    }

    // Incremental Source Generator
    [Benchmark]
    public string IncrementalSourceGen_Blog()
    {
        string result = "";
        for (int i = 0; i < Iterations; i++) result = _blog.ToJsonIncremental();
        return result;
    }

    [Benchmark]
    public string Reflection_Course()
    {
        string result = "";
        for (int i = 0; i < Iterations; i++)
        {
            result = _course.ToJsonReflection(); // Reflection
        }
        return result;
    }

    [Benchmark]
    public string SourceGen_Course()
    {
        string result = "";
        for (int i = 0; i < Iterations; i++)
        {
            result = _course.ToJsonSourceGen(); // Source Generator
        }
        return result;
    }
    
    // Incremental Source Generator
    [Benchmark]
    public string IncrementalSourceGen_Course()
    {
        string result = "";
        for (int i = 0; i < Iterations; i++) result = _course.ToJsonIncremental();
        return result;
    }
}

Step 4: Call the benchmarking summary in Program.cs

Finally, we will run the benchmarking class in the Program.cs file:

using BenchmarkDotNet.Running;
using ComparativeSourceGenerators;

Console.WriteLine("Hello, World!"); 
var summary = BenchmarkRunner.Run<SerializationBenchmarks>();

Step 5: Build the project

Build the project in your IDE or using the console:

dotnet build

Step 6: Run the project

Run the benchmarking in release mode. I have discussed benchmarking if you want to understand its working in-depth.

dotnet run -c Release

As seen in the benchmarking result, reflection is the least-performant. Although its implementation is easier, due to its runtime execution, reflection takes longer. I demonstrated this with a very simple example involving two models. Still, as applications grow larger and models become more complex, this minor difference can translate into a significant performance penalty. However, that does not mean reflection cannot work anywhere, one key metric you can observe is that reflection has used the least memory among all. So there are lots of scenarios where it actually helps that you can learn and leverage with reflection in your project. Source generator is better than reflection. Incremental outperforms others due to its efficient filtering of candidates. On subsequent attempts after you change code, incremental compilation utilizes caching for unchanged parts, eliminating the extra execution of generating code again.

Best Practices for Authoring a Cache-Friendly Incremental Generator

To leverage incremental generators, you need to consider the following while writing an IIncrementalGenerator implementation.

Extract out information early

Instead of carrying large expensive objects of the syntax tree, try to filter out unrelated nodes and symbols to reduce data size early. By extracting only what's needed, you allow the generator pipeline to skip unnecessary recomputation.

Use value types where possible

Incremental generators rely heavily on detecting whether a value has changed since the last run. Value types and immutable collections have cheap and predictable equality checks. Try using value types such as record, struct, tuples, ImmutableArray<T>, where possible.

Use multiple transformations in the pipeline

Each transformation acts like a checkpoint in the pipeline. If only one step is changed, the compiler has to execute all the steps due to a single transform. Instead of writing one complex Select expression, break the code into smaller steps using SelectMany, Combine, etc. So, if some step required re-execution, other steps can rely on cached results instead of regenerating unnecessarily.

Build a Data Model

Passing around raw inputs everywhere is not a good idea for incremental generators. Instead, use an intermediate model as comparing two simple models (structs/records) is faster than comparing large Roslyn symbols.

❌ Bad practice

context.RegisterSourceOutput(classDeclarations, (spc, classDecl) =>
{
    var symbol = compilation.GetSemanticModel(classDecl.SyntaxTree).GetDeclaredSymbol(classDecl);
    var code = $"public class {symbol.Name}Dto {{ }}";
    spc.AddSource($"{symbol.Name}Dto.g.cs", code);
});

✅ Better

record ClassModel(string Name, string Namespace);

var classModels = classDeclarations
    .Select((classDecl, ct) =>
    {
        var symbol = compilation.GetSemanticModel(classDecl.SyntaxTree).GetDeclaredSymbol(classDecl);
        return new ClassModel(symbol.Name, symbol.ContainingNamespace.ToString());
    });

context.RegisterSourceOutput(classModels, (spc, model) =>
{
    var code = $"namespace {model.Namespace} {{ public class {model.Name}Dto {{ }} }}";
    spc.AddSource($"{model.Name}Dto.g.cs", code);
});

Consider the order of combines

In incremental generators, Combine() joins two pipelines. The order of the combine method matters because if you combine too early with something unstable (like compilation), your cache becomes useless. Ensure filtering occurs earlier to combine only minimal information, similar to tip 1.

❌ Bad idea

var combined = texts.Combine(compilation);
context.RegisterSourceOutput(combined, (spc, pair) =>
{
    var assemblyName = pair.Right.AssemblyName; // compilation-dependent
    var text = pair.Left.GetText().ToString();
    // generate code
});

Here, any small change in compilation, such as even typing a space in the IDE, can cause the whole pipeline to re-run, even if the texts didn't change. This makes the first approach very cache-unfriendly.

✅ Better

var assemblyName = context.CompilationProvider
    .Select((c, _) => c.AssemblyName);

var textModels = context.AdditionalTextsProvider
    .Select((t, _) => t.GetText()?.ToString());

var combined = textModels.Combine(assemblyName);

context.RegisterSourceOutput(combined, (spc, pair) =>
{
    var (text, asmName) = pair;
    // only runs if text OR asmName changes
    spc.AddSource($"{asmName}_Generated.g.cs", text ?? "");
});

Now we only keep the assembly name, which is a cheap and stable string, reducing cache invalidations.

Conclusion

Source generators are a new addition in .NET SDK 6 that allows developers to automate code generation. Source generators are useful in avoiding tedious, error-prone code, avoiding reflection in high-performance scenarios. An incremental source generator is an upgraded generator that utilizes caching to store results. I discussed in detailed how to implement generators and how the new player is better with a benchmarking summary. You can opt for an incremental generator in your project. Incremental source generators are not only an efficient version of source generators, but they are a successor and prior versions are deprecated.