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.