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

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

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

If you have ever stared at a wall of repetitive boilerplate and thought "there has to be a better way" -- you are right. Understanding how to create a C# source generator is one of those skills that pays dividends across your entire codebase. Source generators are a Roslyn compiler feature that let you write code that writes code -- at compile time, with full type-system awareness, and at zero runtime cost.

Unlike T4 templates or reflection-based tricks, source generators are fast, transparent, and ship no runtime overhead. They run during compilation, inspect your code's syntax tree and semantic model, and inject new C# files directly into the compilation. The generated code shows up in IntelliSense, is fully debuggable, and is as real as any file you wrote by hand.

Frameworks across the .NET ecosystem already rely on this technique heavily. If you have used a plugin-based DI framework like Needlr for modular application assembly, you have already benefited from source generation working behind the scenes to wire up registrations at compile time -- eliminating runtime reflection overhead entirely.

In this guide you will build your first source generator from scratch using IIncrementalGenerator -- the modern, performance-optimized API that replaced the now-deprecated ISourceGenerator in .NET 6. The older interface is deprecated and should not be used in new projects.

By the end, you will have a working generator that detects a custom marker attribute, generates a partial class for every class that uses it, and is ready to be packaged as a NuGet library. You will also learn how to attach the debugger, emit Roslyn diagnostics, and understand why the generator project must target netstandard2.0 even though your consuming project happily targets .NET 8 or later.

Let's get into it.

Prerequisites

Before writing a single line of C# source generator code, you need a few things in place.

SDK and tooling. You need the .NET 8 SDK or later. Visual Studio 2022 (17.6+), JetBrains Rider 2023.3+, and VS Code with the C# Dev Kit extension all support live viewing of generated files under the Analyzers node. Any of the three will work for this guide.

NuGet packages. The only required NuGet package for your source generator project is Microsoft.CodeAnalysis.CSharp. This ships as part of the Roslyn compiler platform and provides all the syntax tree APIs, semantic model access, symbol resolution, and pipeline primitives you need to analyze user code and generate output. Pin it to at least 4.9.2 for .NET 8 SDK compatibility. For .NET 10 toolchains, use the Roslyn version bundled with your SDK -- for .NET 10 that is 4.12.0 or later.

You do not need to install Roslyn separately -- it is already embedded in the SDK. The NuGet package reference is needed only so the source generator project knows which Roslyn APIs to compile against.

A note on netstandard2.0. Your source generator project must target netstandard2.0. This is not optional and cannot be worked around. The MSBuild analyzer host -- the in-process component that loads your generator DLL during compilation -- can run on older runtimes depending on the SDK version and OS. Targeting netstandard2.0 ensures your source generator DLL loads cleanly in all supported host contexts. Your consumer project can target net8.0, net9.0, net10.0, or any other modern TFM without restriction. The constraint applies only to the source generator assembly itself.

Project Structure

The canonical C# source generator solution uses two projects living side by side:

  • MyGenerator -- the source generator project (targets netstandard2.0, produces the DLL that Roslyn loads)
  • MyConsumerApp -- any project that consumes the source generator (targets net8.0 or later)

A minimal folder layout looks like this:

MyGeneratorSolution/
├── MyGenerator/
│   ├── MyGenerator.csproj
│   └── HelloWorldGenerator.cs
└── MyConsumerApp/
    ├── MyConsumerApp.csproj
    └── Program.cs

The source generator project compiles to a regular .dll, but MSBuild treats it as an analyzer rather than a normal library reference. That distinction is controlled by a special attribute on the <ProjectReference> in the consumer, which we will cover in Step 3. Nothing fancy is required on the source generator project's side -- it is a standard class library with a special target framework and a few extra MSBuild properties.

Step 1: Create the Generator Project

Create a new class library named MyGenerator -- this will be your source generator project. Open the .csproj file and replace its contents with the following:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <Nullable>enable</Nullable>
    <LangVersion>latest</LangVersion>
    <IsRoslynComponent>true</IsRoslynComponent>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" PrivateAssets="all" />
  </ItemGroup>
</Project>

Two properties here deserve explicit attention.

IsRoslynComponent tells the SDK that this project is a Roslyn analyzer or source generator. It activates additional build validation, ensures the output DLL lands in the correct NuGet package subfolder when you publish, and enables the extended analyzer rules checked by the next property.

EnforceExtendedAnalyzerRules is strongly recommended for all new source generator projects. It enables a stricter set of compile-time rules that catch common source generator mistakes early -- such as accidentally accessing APIs that are unsafe to call inside the concurrent incremental pipeline. Better to find these issues at source generator compile time than to discover them as race conditions in consumer builds.

Setting PrivateAssets="all" on the Microsoft.CodeAnalysis.CSharp reference is equally important. It tells NuGet that this dependency is for compiling the generator itself and should not propagate into any project that later installs your generator as a NuGet package. Without it, consumers would get an unnecessary transitive reference to the Roslyn APIs.

Note that LangVersion is set to latest. Even though the target framework is netstandard2.0, you can use C# 12 and 13 language features freely in your generator source code -- language version is independent of target framework in modern .NET.

Step 2: Implement IIncrementalGenerator

With the source generator project configured, create a file called HelloWorldGenerator.cs. Every C# source generator must implement IIncrementalGenerator from Microsoft.CodeAnalysis and be annotated with the [Generator] attribute so Roslyn discovers it.

Here is the minimal working implementation that uses IIncrementalGenerator to generate a static HelloWorld helper class into every project that references this source generator:

using Microsoft.CodeAnalysis;

namespace MyGenerator;

[Generator]
public sealed class HelloWorldGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // RegisterPostInitializationOutput runs once, unconditionally.
        // Use it for output that does not depend on the user's code at all.
        context.RegisterPostInitializationOutput(static ctx =>
        {
            ctx.AddSource("HelloWorld.g.cs", """
                // <auto-generated/>
                namespace MyGenerator.Generated;

                public static class HelloWorld
                {
                    public static string Greet(string name) => $"Hello, {name}!";
                }
                """);
        });
    }
}

The Initialize method is the single entry point for all generators. Inside it you build a pipeline of incremental steps, each feeding into the next. Roslyn's caching layer tracks what changed between builds and re-runs only the stages whose inputs actually changed. That design is what makes IIncrementalGenerator dramatically faster than the deprecated ISourceGenerator, which performed a full regeneration pass on every keystroke in the IDE.

RegisterPostInitializationOutput is the simplest pipeline stage. It runs once per compilation and adds a source file unconditionally, with no access to the user's syntax tree. This makes it the right place for fixed content -- marker attributes, shared utility classes, or constants that your generator's runtime behavior depends on.

ctx.AddSource takes a unique hint name (the first argument) and the source text. The .g.cs suffix in the hint name is a convention that signals to both humans and tooling that this file was machine-generated.

Build the solution. If the source generator project compiles cleanly you have a working source generator assembly. The next step connects it to a consumer.

Step 3: Reference the Generator from Your Consumer Project

Open the consumer project's .csproj and add this <ProjectReference> to wire up the source generator:

<ItemGroup>
  <ProjectReference Include="..MyGeneratorMyGenerator.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>

OutputItemType="Analyzer" is the essential attribute. Without it, MSBuild treats the reference as an ordinary assembly reference and Roslyn never loads the source generator. With it, the compiled DLL is passed to the Roslyn compiler host alongside any traditional analyzers.

ReferenceOutputAssembly="false" tells MSBuild not to add the source generator DLL to the consuming project's output references. Your consumer should not ship the source generator at runtime -- it is a compile-time-only tool. Omitting this attribute would copy the source generator DLL into your application's output folder, which is both wasteful and potentially confusing during deployment.

After adding this reference, rebuild the consumer project. You should immediately see MyGenerator.Generated.HelloWorld available in IntelliSense. Call HelloWorld.Greet("World") in Program.cs to confirm it compiles and executes correctly.

Step 4: Verify Generated Files in the IDE

Most IDEs provide a way to browse the files your C# source generator produced at compile time. In Visual Studio 2022, the path is:

  1. Expand the consumer project in Solution Explorer.
  2. Open Dependencies > Analyzers > MyGenerator > MyGenerator.HelloWorldGenerator.
  3. You should see HelloWorld.g.cs listed there.

Double-clicking the file opens it as a read-only view, showing you the exact text your generator produced. This is one of the most valuable debugging surfaces you have -- you can verify the generated output without running or deploying anything.

In JetBrains Rider, generated files appear under External Sources or a Generated virtual folder depending on your version. In VS Code with C# Dev Kit, right-click any generated symbol and choose Go to Definition to navigate directly to the generated source.

If the file does not appear, the most common cause is a build that did not complete cleanly. Check the Error List or build output for Roslyn diagnostic errors emitted by your source generator. A compile error in the source generator assembly prevents it from loading at all -- no error from the consumer project's perspective, just silence where the generated code should be.

Step 5: Generate Something Useful -- Detecting a Marker Attribute

Static output is a start, but the real power of source generation comes from responding to user code. The most common pattern is a marker attribute -- a custom [Attribute] that developers apply to their own classes to opt into generated behavior. You can think of this the same way you might opt a class into auto-generated factory registration code or an auto-built builder class -- a single attribute declares intent, and the generator handles the repetitive wiring.

There are two parts to this pattern: injecting the attribute definition itself into the compilation, and then scanning the user's syntax tree for classes that use it.

Injecting the Marker Attribute

You want the attribute to be available in the consuming project without requiring a separate assembly reference. The cleanest approach is to inject the attribute definition through RegisterPostInitializationOutput:

private const string AttributeFullName = "MyGenerator.GenerateHelperAttribute";

private const string AttributeSource = """
    // <auto-generated/>
    namespace MyGenerator;

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

In your Initialize method, register it before building any other pipeline stages:

context.RegisterPostInitializationOutput(static ctx =>
    ctx.AddSource("GenerateHelperAttribute.g.cs", AttributeSource));

Now any class in the consuming project can be decorated with [MyGenerator.GenerateHelper] and your pipeline will detect it.

Building the Incremental Pipeline

Here is a complete, realistic generator that detects [GenerateHelper] on a class and generates a partial class with a GetTypeName() helper method. This is a solid template for almost any attribute-driven generator you will write:

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Text;

namespace MyGenerator;

[Generator]
public sealed class HelperGenerator : IIncrementalGenerator
{
    private const string AttributeFullName = "MyGenerator.GenerateHelperAttribute";

    private const string AttributeSource = """
        // <auto-generated/>
        namespace MyGenerator;

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

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Inject the marker attribute into every compilation
        context.RegisterPostInitializationOutput(static ctx =>
            ctx.AddSource("GenerateHelperAttribute.g.cs", AttributeSource));

        // Stage 1: predicate is pure syntax (fast, no semantic model). Transform resolves the attribute type via semantic model -- only runs for classes that passed the predicate.
        IncrementalValuesProvider<ClassDeclarationSyntax?> classDeclarations =
            context.SyntaxProvider
                .CreateSyntaxProvider(
                    predicate: static (node, _) =>
                        node is ClassDeclarationSyntax cls && cls.AttributeLists.Count > 0,
                    transform: static (ctx, _) =>
                    {
                        var cls = (ClassDeclarationSyntax)ctx.Node;
                        foreach (var attrList in cls.AttributeLists)
                        {
                            foreach (var attr in attrList.Attributes)
                            {
                                var symbol = ctx.SemanticModel.GetSymbolInfo(attr).Symbol;
                                if (symbol?.ContainingType?.ToDisplayString() == AttributeFullName)
                                    return cls;
                            }
                        }
                        return null!;
                    })
                .Where(static cls => cls is not null);

        // Stage 2: combine with the compilation for full symbol resolution
        var combined = context.CompilationProvider.Combine(classDeclarations.Collect());

        context.RegisterSourceOutput(combined, static (spc, source) =>
            Execute(source.Left, source.Right, spc));
    }

    private static void Execute(
        Compilation compilation,
        ImmutableArray<ClassDeclarationSyntax> classes,
        SourceProductionContext context)
    {
        foreach (var cls in classes)
        {
            var model = compilation.GetSemanticModel(cls.SyntaxTree);
            if (model.GetDeclaredSymbol(cls) is not INamedTypeSymbol typeSymbol)
                continue;

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

            context.AddSource(
                $"{typeSymbol.Name}.Helper.g.cs",
                BuildSource(ns, typeSymbol.Name));
        }
    }

    private static string BuildSource(string namespaceName, string className)
    {
        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated/>");
        sb.AppendLine("#nullable enable");
        sb.AppendLine();
        if (!string.IsNullOrEmpty(namespaceName))
        {
            sb.AppendLine($"namespace {namespaceName};");
            sb.AppendLine();
        }
        sb.AppendLine($"partial class {className}");
        sb.AppendLine("{");
        sb.AppendLine($"    public static string GetTypeName() => nameof({className});");
        sb.AppendLine("}");
        return sb.ToString();
    }
}

The pipeline has three logical stages. CreateSyntaxProvider first runs a cheap syntactic predicate -- checking whether a syntax node is a ClassDeclarationSyntax with at least one attribute list. This pass runs on every node in the entire syntax tree and must be fast. The second part of CreateSyntaxProvider is the transform: for each class that passed the predicate, the semantic model resolves the attribute's full type name and returns the class if it matches GenerateHelperAttribute. The Where call drops nulls. Finally, RegisterSourceOutput fires Execute with both the filtered class list and the full compilation.

Inside Execute, compilation.GetSemanticModel gives you the INamedTypeSymbol for each class, which provides the namespace, any generic type parameters, base types, and everything else required for sophisticated generation scenarios. The incremental caching layer ensures Execute only re-runs when the set of decorated classes actually changes -- not on every keystroke.

Generators like this one are at the heart of how decorator wrappers can be auto-generated for cross-cutting concerns such as logging, caching, and validation, and how strategy dispatch tables can be produced without any hand-written switch statements or runtime reflection.

Debugging Your Generator

Debugging a C# source generator requires a slightly different approach than debugging application code -- but it is fully supported and not particularly difficult once you know the trick.

Using Debugger.Launch()

The most reliable method is to insert a Debugger.Launch() call at the start of Initialize, wrapped in a #if DEBUG conditional. This is standard practice when developing a source generator locally:

public void Initialize(IncrementalGeneratorInitializationContext context)
{
#if DEBUG
    if (!System.Diagnostics.Debugger.IsAttached)
        System.Diagnostics.Debugger.Launch();
#endif
    // ... rest of Initialize
}

When the consumer project builds in Debug configuration, Debugger.Launch() triggers a Just-In-Time debugger prompt. Select the Visual Studio instance that has your generator solution open and you can step through generator code with full breakpoint support and locals inspection. Remove the call before committing -- leaving it in a published source generator makes every consumer's build pause waiting for a debugger attachment.

Emitting diagnostics

Source generators can also report Diagnostic objects through SourceProductionContext. This is useful for warning or erroring on invalid usage -- for example, when the decorated class is missing the partial modifier required for the generated partial class to compile:

var diagnostic = Diagnostic.Create(
    new DiagnosticDescriptor(
        id: "MG001",
        title: "Missing partial modifier",
        messageFormat: "Class '{0}' decorated with [GenerateHelper] must be declared partial",
        category: "MyGenerator",
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true),
    cls.GetLocation(),
    typeSymbol.Name);

context.ReportDiagnostic(diagnostic);

These diagnostics appear inline as red squiggles in the IDE and in the build output. They give consumers immediate, actionable feedback rather than a mysterious compile error from the generated file.

Packaging Your Generator as a NuGet Package

Once your C# source generator is working, sharing it as a NuGet package requires a few additional properties in the .csproj and one important packaging convention.

Roslyn source generators must be placed at the path analyzers/dotnet/cs/ inside the NuGet package. When IsRoslynComponent is true, the SDK handles this automatically -- the source generator DLL ends up in the correct folder in the .nupkg without any manual <None Include> wiring.

Add these properties to the source generator's .csproj:

<PropertyGroup>
  <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
  <PackageId>MyCompany.MyGenerator</PackageId>
  <Version>1.0.0</Version>
  <Authors>Your Name</Authors>
  <Description>Generates helper methods for classes decorated with [GenerateHelper].</Description>
  <!-- Prevents the generator DLL from landing in lib/, which would make it a runtime ref -->
  <IncludeBuildOutput>false</IncludeBuildOutput>
  <!-- Prevents Microsoft.CodeAnalysis.CSharp from appearing as a package dependency -->
  <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>

IncludeBuildOutput=false prevents the source generator DLL from ending up in the lib/netstandard2.0 folder of the package, which would cause NuGet to treat it as a normal runtime reference rather than an analyzer. SuppressDependenciesWhenPacking=true removes all package dependencies from the .nuspec, not just Roslyn. PrivateAssets="all" on the Roslyn reference is already sufficient to exclude it specifically -- if you ever add a non-Roslyn package your generator needs at compile time, be aware that this property will also suppress it.

Run dotnet pack to produce the .nupkg. Inspect the result with NuGet Package Explorer and verify the source generator DLL lands under analyzers/dotnet/cs/ -- that is the path the NuGet client looks for when enabling analyzer and source generator assemblies automatically upon package install.

Frequently Asked Questions

Why must the generator project target netstandard2.0?

Roslyn's analyzer host -- the component inside the SDK that loads your C# source generator DLL at build time -- runs on older runtimes in some SDK configurations, particularly on older CI images or when building inside Visual Studio's in-process compiler. Targeting netstandard2.0 guarantees your source generator loads cleanly regardless of the host runtime version. Your generated output and the consuming project can still target net8.0, net9.0, net10.0, or anything else. The netstandard2.0 constraint is solely for the source generator binary itself.

Can I use ISourceGenerator instead of IIncrementalGenerator?

Technically you can, but you should not. ISourceGenerator is deprecated as of .NET 6 and may be removed in a future SDK version. More critically, it performs a full regeneration pass on every keystroke in the IDE, which causes severe performance degradation in large codebases. IIncrementalGenerator uses a caching pipeline that tracks which inputs changed and re-runs only the affected stages. The difference becomes obvious quickly -- for any new generator, IIncrementalGenerator is the right choice. ISourceGenerator is deprecated and will degrade IDE performance at scale -- there is no scenario in new code where you'd prefer it.

What are real-world use cases for creating a C# source generator?

The list is extensive and growing. Popular frameworks use source generators heavily today. DI frameworks like Needlr use them to register cross-cutting concerns at compile time, eliminating runtime reflection entirely. You can use a generator to produce factory registration boilerplate that used to require hand-written switch statements, or to auto-generate builder classes for complex domain objects. Generators can produce decorator implementations for logging and caching wrappers, and emit strategy dispatch tables without any runtime reflection. Any pattern that involves type-aware, repetitive boilerplate is a good candidate for generation.

How do I generate code based on multiple attributes or combine providers?

Use the Combine method available on IncrementalValueProvider<T> and IncrementalValuesProvider<T>. You can combine multiple providers into a tuple and pass the combined result into a single RegisterSourceOutput stage. For example, you might combine a provider of all [GenerateHelper]-decorated classes with a separate provider of all [GenerateHelperConfig]-decorated classes to produce context-aware output that considers both. The incremental caching layer tracks each provider independently -- your execution callback only re-runs when any of its inputs actually change.

Why does my generator not update in the IDE after rebuilding?

This is almost always a stale analyzer cache. Visual Studio and Rider cache source generator assemblies aggressively. The reliable fix is to close the IDE entirely, delete the bin and obj directories from both the source generator and consumer projects, then reopen and build clean. Many developers keep a short PowerShell script to do exactly this during active source generator development. Also confirm that your hint names are unique and deterministic -- if two source generators produce a file with the same hint name in the same compilation, only one will win and the other's output is silently discarded.

Can I write unit tests for my source generator?

Yes, and you should. The standard approach uses the Microsoft.CodeAnalysis.Testing packages -- specifically Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing.XUnit for xUnit. These packages let you supply source text as a string, run your C# source generator against it in-process, and assert on the generated output text. Testing source generators in isolation is significantly faster than integration-testing them through a full MSBuild pipeline and gives you precise, reproducible coverage of your pipeline logic.

How do I make the generator emit correct code for generic or nested classes?

Generic classes require you to read typeSymbol.TypeParameters from the INamedTypeSymbol and reproduce the type parameter list in the generated partial class declaration. Nested classes require you to check typeSymbol.ContainingType and emit the outer class declarations as well -- a partial class must appear within the same nesting structure as its counterpart in user code. Both are straightforward once you have the INamedTypeSymbol in hand; the symbol API gives you everything you need to reconstruct the full declaration context.

Wrapping Up

You now know how to create a C# source generator from the ground up -- from configuring the netstandard2.0 project and wiring up the Roslyn NuGet package, through implementing a real IIncrementalGenerator pipeline driven by a marker attribute, all the way to packaging the source generator for NuGet distribution and attaching a debugger.

The key ideas to carry forward are straightforward. Always use IIncrementalGenerator -- the older ISourceGenerator interface is deprecated and will degrade IDE performance at scale. There is no scenario in new code where you'd prefer it. Set IsRoslynComponent=true and EnforceExtendedAnalyzerRules=true in your source generator's .csproj to activate SDK-level validation. Reference the source generator from consumers with OutputItemType="Analyzer" and ReferenceOutputAssembly="false". Use RegisterPostInitializationOutput for fixed content like marker attributes, and CreateSyntaxProvider with a fast predicate plus a semantic transform to build incremental pipelines that respond to user code.

C# source generators unlock a fundamentally different way of thinking about code architecture. Once you have shipped your first one, you will start seeing opportunities everywhere -- automating away repetitive patterns, reducing error-prone hand-written boilerplate, and making large codebases more consistent and maintainable. The investment in learning the Roslyn API is real, but it pays back quickly in every project that benefits.

Start small, write tests against the generated output, and expand from there. Happy generating!

How to Implement Builder Pattern in C#: Step-by-Step Guide

Learn how to implement Builder pattern in C# with a complete step-by-step guide. Includes code examples, best practices, and common pitfalls to avoid.

How to Implement Decorator Pattern in C#: Step-by-Step Guide

Learn how to implement decorator pattern in C# with step-by-step code examples covering interfaces, abstract decorators, and decorator chaining.

How to Implement Singleton Pattern in C#: Step-by-Step Guide

How to implement Singleton pattern in C#: step-by-step guide with code examples, thread-safe implementation, and best practices for creational design patterns.

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