Mert Özen Further With Every Line
Backend Advanced C# — #11

Source Generators in C#: Generating Code at Compile Time with IIncrementalGenerator

Mert Özen Nov 7, 2026 9 min 5 views
Source Generators in C#: Generating Code at Compile Time with IIncrementalGenerator

Writing an incremental generator that adds code during the build: the netstandard2.0 project and Roslyn version, the pipeline and caching, seeing the generated code, and the partial and hint name pitfalls.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

A small console application with two files. Program.cs calls a Describe() method on Book and Author:

using Bookstore;

var book = new Book { Isbn = "978-0441013593", Title = "Dune", Price = 9.99m };
Console.WriteLine(book.Describe());

var author = new Author { Name = "Frank Herbert", BookCount = 23 };
Console.WriteLine(author.Describe());

Models.cs has the two classes, but there is no method called Describe(). The GenerateDescribe attribute isn't defined in any file in the project either. Does this project compile?

namespace Bookstore;

[GenerateDescribe]
public partial class Book
{
    public string Isbn { get; init; } = "";
    public string Title { get; init; } = "";
    public decimal Price { get; init; }
}

[GenerateDescribe]
public partial class Author
{
    public string Name { get; init; } = "";
    public int BookCount { get; init; }
}

Writing Code Inside the Compiler

It compiles and runs:

Book { Isbn = 978-0441013593, Title = Dune, Price = 9.99 }
Author { Name = Frank Herbert, BookCount = 23 }

A source generator wrote the missing pieces. A source generator is a small program that plugs into the compiler and runs during the build: it reads your code and adds new C# files to the compilation. Here the generator first produced the GenerateDescribeAttribute class, then, for every class carrying the attribute, a partial class containing the Describe() method. The compiler compiled those files as if you had written them.

Two limits are worth knowing up front. A generator only adds code; it can't change the code you wrote or insert lines into a method. That's why Book and Author are partial: the generator writes the second half of the class in a separate file. Second, a generator runs at compile time, not at run time. In the attributes and reflection post we could have done the same job with reflection (walking the properties at run time), but you'd pay on every call, and it can cause problems with trimming or Native AOT. The code a generator produces is ordinary, static C#: fast and in plain view of the compiler.

.NET 6 itself uses this approach: System.Text.Json's source generation mode produces serialization code at compile time, and the [LoggerMessage] attribute writes high-performance logging methods. The [LibraryImport] we mentioned in the previous post and .NET 7's Regex generator belong to the same family.

The Generator Project

A generator is a separate class library. It has to target netstandard2.0, because the compiler runs both inside Visual Studio (on .NET Framework) and in dotnet build (on .NET), and the generator must load into both:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>10</LangVersion>
    <Nullable>enable</Nullable>
    <IsRoslynComponent>true</IsRoslynComponent>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3" PrivateAssets="all" />
  </ItemGroup>
</Project>

The Microsoft.CodeAnalysis.CSharp package is the API of the compiler (Roslyn). The version matters: a generator can't reference a newer Roslyn than the compiler loading it. According to Microsoft's version table, 4.0.1 is the Roslyn that matches the first release of Visual Studio 2022, that is, the compiler that shipped with .NET 6. A generator referencing this version works in every tool from the C# 10 era. PrivateAssets="all" keeps these packages from leaking into projects that use the generator; IsRoslynComponent is there so you can debug the generator in Visual Studio.

The Generator Itself

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

namespace Bookstore.Generators;

[Generator]
public sealed class DescribeGenerator : IIncrementalGenerator
{
    private const string AttributeName = "Bookstore.GenerateDescribeAttribute";

    private const string AttributeSource = @"// <auto-generated/>
namespace Bookstore
{
    [System.AttributeUsage(System.AttributeTargets.Class)]
    internal sealed class GenerateDescribeAttribute : System.Attribute
    {
    }
}
";

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1) Add the attribute itself to every compilation
        context.RegisterPostInitializationOutput(static ctx =>
            ctx.AddSource("GenerateDescribeAttribute.g.cs", SourceText.From(AttributeSource, Encoding.UTF8)));

        // 2) Find classes with an attribute and extract what we need
        var classes = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (node, _) => node is ClassDeclarationSyntax c && c.AttributeLists.Count > 0,
                transform: static (ctx, ct) => ToModel(ctx, ct))
            .Where(static m => m is not null);

        // 3) Produce one file per class
        context.RegisterSourceOutput(classes, static (spc, model) =>
            spc.AddSource($"{model!.Value.Name}.Describe.g.cs", SourceText.From(model.Value.Source, Encoding.UTF8)));
    }

    private static (string Name, string Source)? ToModel(GeneratorSyntaxContext ctx, System.Threading.CancellationToken ct)
    {
        if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node, ct) is not INamedTypeSymbol type)
            return null;

        bool marked = type.GetAttributes().Any(a => a.AttributeClass?.ToDisplayString() == AttributeName);
        if (!marked)
            return null;

        var properties = type.GetMembers()
            .OfType<IPropertySymbol>()
            .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
            .Select(p => $"{p.Name} = {{{p.Name}}}");

        string body = string.Join(", ", properties);
        string ns = type.ContainingNamespace.IsGlobalNamespace ? "" : type.ContainingNamespace.ToDisplayString();

        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated/>");
        if (ns.Length > 0)
            sb.AppendLine($"namespace {ns}").AppendLine("{");
        sb.AppendLine($"    partial class {type.Name}");
        sb.AppendLine("    {");
        sb.AppendLine($"        public string Describe() => global::System.FormattableString.Invariant($\"{type.Name} {{{{ {body} }}}}\");");
        sb.AppendLine("    }");
        if (ns.Length > 0)
            sb.AppendLine("}");

        return (type.Name, sb.ToString());
    }
}

The [Generator] attribute and the IIncrementalGenerator interface tell the compiler "run me". The only method is Initialize, and we don't generate code in it; we describe a pipeline. There are three steps.

The first is RegisterPostInitializationOutput: fixed code added to every compilation, independent of the user's code. We provide the attribute from here, so the project using the generator doesn't need a separate package reference. The attribute is internal because every project gets its own copy.

The second is CreateSyntaxProvider: a two-stage filter. predicate is called for every node in the syntax tree, so it must be cheap: we only check "is this a class with attributes?". For the nodes that pass, transform runs; here we ask the semantic model: is one of this class's attributes really Bookstore.GenerateDescribeAttribute? (Looking at the name alone isn't enough; there could be an attribute with the same name in another namespace.) If it matches, we collect the public properties and prepare the source we'll generate.

The third is RegisterSourceOutput: for every model that comes out of the pipeline, we add a file with AddSource. The first parameter is the file's name (the hint name); it must be unique within the generator.

What "Incremental" Means

Generators don't run only in dotnet build; they also run in the IDE, as you type, on every keystroke. If a generator rescanned the whole project every time, the editor would freeze. The first API, which came with C# 9 (ISourceGenerator), had exactly this problem; IIncrementalGenerator, which came with Roslyn 4.0, that is in the .NET 6 era, solved it with the pipeline model. The Roslyn team now recommends writing new generators with the incremental API.

The idea is this: the output of every step in the pipeline is cached. When you change a file, only that file's nodes go through predicate again. transform produces a new model, and if that model equals the previous one, the later steps don't run at all. That's why the model's equality comparison is critical. Our model is a (string Name, string Source) tuple; tuples and strings compare by value. When you change a method body in Book, the property list stays the same, so the model is the same and the file isn't regenerated.

ForAttributeWithMetadataName, which came with Roslyn 4.3.1 (Visual Studio 2022 17.3), does the "find the types carrying this attribute" job much faster using the compiler's own indexes, and it's the recommended approach today. Our project, which references 4.0.1, doesn't have this method (when I tried to build it, I got CS1061); with 4.3.1 the second step becomes:

var classes = context.SyntaxProvider.ForAttributeWithMetadataName(
    "Bookstore.GenerateDescribeAttribute",
    predicate: static (node, _) => true,
    transform: static (ctx, _) => ((INamedTypeSymbol)ctx.TargetSymbol).Name);

Using the Generator and Seeing the Generated Code

The application project doesn't connect to the generator with an ordinary project reference; it connects to it as an analyzer:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <LangVersion>10</LangVersion>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
    <CheckEolTargetFramework>false</CheckEolTargetFramework>
    <NuGetAudit>false</NuGetAudit>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\Bookstore.Generators\Bookstore.Generators.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false" />
  </ItemGroup>
</Project>

OutputItemType="Analyzer" loads the generator into the compiler; ReferenceOutputAssembly="false" keeps the generator DLL from becoming a dependency of the application. If you ship the generator as a NuGet package, the DLL goes into the package's analyzers/dotnet/cs folder and has the same effect.

EmitCompilerGeneratedFiles writes the generated files to disk. Normally they live only in memory; in the IDE you can see them with "Go to Definition", but on the CLI you can't see them without this setting. After the build, in the obj folder:

obj/Debug/net6.0/generated/Bookstore.Generators/Bookstore.Generators.DescribeGenerator/Author.Describe.g.cs
obj/Debug/net6.0/generated/Bookstore.Generators/Bookstore.Generators.DescribeGenerator/Book.Describe.g.cs
obj/Debug/net6.0/generated/Bookstore.Generators/Bookstore.Generators.DescribeGenerator/GenerateDescribeAttribute.g.cs

The file generated for Book:

// <auto-generated/>
namespace Bookstore
{
    partial class Book
    {
        public string Describe() => global::System.FormattableString.Invariant($"Book {{ Isbn = {Isbn}, Title = {Title}, Price = {Price} }}");
    }
}

Note three details. The // <auto-generated/> comment keeps analyzers and style rules from raising warnings in this file. We wrote the namespace as a block, not file-scoped; a generator's output should compile without knowing the language version of the project using it. The global:: prefix and FormattableString.Invariant keep the generated code independent of the user's usings and culture; Price prints 9.99 even on a Turkish machine.

Common Pitfalls

Forgetting partial. If you put the attribute on a class that isn't partial, the generator still produces the second half and the compiler can't merge the two declarations:

namespace Bookstore;

[GenerateDescribe]
public class Order
{
    public int Id { get; init; }
    public decimal Total { get; init; }
}
Order.cs(4,14): error CS0260: Missing partial modifier on declaration of type 'Order'; another partial declaration of this type exists

The error shows up in the user's file, but its cause isn't visible; it's confusing. Mature generators catch this case themselves and give a clear "this class must be partial" message with ReportDiagnostic. It's the first feature worth adding to your own generator.

Models that break the cache. Returning an ISymbol, a SyntaxNode, a SemanticModel or an ImmutableArray<T> from transform looks practical at first. But these are either new objects on every compilation or have no value equality; every keystroke produces a model that isn't equal, and the "incremental" generator runs from scratch every time. Build the model from strings, numbers and your own types with value equality.

Using the same hint name twice. If there are classes named Book in two different namespaces, our generator tries to add Book.Describe.g.cs twice:

namespace Bookstore
{
    [GenerateDescribe]
    public partial class Book
    {
        public string Title { get; init; } = "";
    }
}

namespace Bookstore.Archive
{
    [GenerateDescribe]
    public partial class Book
    {
        public int Year { get; init; }
    }
}
CSC : warning CS8785: Generator 'DescribeGenerator' failed to generate source. It will not contribute to the output and compilation errors may occur as a result. Exception was of type 'ArgumentException' with message 'The hintName 'Book.Describe.g.cs' of the added source file must be unique within a generator. (Parameter 'hintName')'.
Program.cs(1,40): error CS1061: 'Book' does not contain a definition for 'Describe' and no accessible extension method 'Describe' accepting a first argument of type 'Book' could be found (are you missing a using directive or an assembly reference?)
Program.cs(2,48): error CS1061: 'Book' does not contain a definition for 'Describe' and no accessible extension method 'Describe' accepting a first argument of type 'Book' could be found (are you missing a using directive or an assembly reference?)

The exception inside the generator didn't stop the build; the compiler turned it into a CS8785 warning and took none of the generator's output into the compilation. The result is CS1061 errors that appear far from the real cause. Someone who doesn't read the warning list will spend hours asking "why is there no Describe?". In a real generator you include the namespace in the hint name.

The IDE holding on to an old generator. When you change the generator's code, a CLI build uses the new version right away, but IDEs can keep the loaded generator cached for a while. The answer to "why doesn't my change show up?" is often to restart the IDE. The real way to verify a generator is a unit test: create a compilation with CSharpGeneratorDriver and check the generated source. We'll talk about unit tests in the last post of the series.

A Small Experiment

★☆☆ Add public int Stock { get; init; } to Book and build. What changed in the generated file? What happens if you add a private property?

★★☆ Add a diagnostic to the generator: if the attributed class isn't partial (look at ClassDeclarationSyntax.Modifiers), don't generate code; report an error with the id BS0001 and the message "Class 'Order' must be partial" instead. Which error do you see in the NoPartial project now?

★★★ Add the namespace to the hint name and build again with the two Book classes in the Dup project. Then build the model with your own record instead of a tuple (find out how to solve the IsExternalInit problem that record runs into on netstandard2.0).

Generators were a way to escape reflection's run-time cost by producing code at compile time. In the next post we look at how to prove "faster" claims: allocations, structs, and measuring with BenchmarkDotNet.