C# Source Generators on .NET 10: Tutorial and Common Errors

Last updated: October 12, 2026 · Tested with .NET 10 (SDK 10.0.401, Roslyn 5.9) and .NET 9 (SDK 9.0.312, Roslyn 4.14), Microsoft.CodeAnalysis.CSharp 4.14.0, Windows 11

Short answer: a C# source generator is a class that runs inside the compiler and adds C# files to your build. On .NET 10 you write one as an IIncrementalGenerator. Put it in a netstandard2.0 class library that references Microsoft.CodeAnalysis.CSharp, and reference that project with OutputItemType="Analyzer" ReferenceOutputAssembly="false". Below I build a complete generator that writes ToString() for any class marked [AutoToString], then break it on purpose. You’ll see the real errors: CS0616, CS9057, CS0518, RS2008 and RS1041, plus a generator that silently loses its caching. Everything was built and run with the .NET 10 and .NET 9 SDKs, and the output is copied from the console.

What We’re Building

The goal: mark a class with an attribute, and the build adds a ToString() that lists its public properties. No reflection at runtime, so it also works with trimming and Native AOT:

using AutoToString;

var order = new Order { Id = 42, Customer = "Ada", Total = 99.50m };
Console.WriteLine(order);
Console.WriteLine(new Shop.Product { Sku = "A-1", Price = 5m });

[AutoToString]
public partial class Order
{
    public int Id { get; set; }
    public string Customer { get; set; } = "";
    public decimal Total { get; set; }
    private string Secret { get; set; } = "hidden"; // not public: left out
}

namespace Shop
{
    [AutoToString]
    public partial class Product
    {
        public string Sku { get; set; } = "";
        public decimal Price { get; set; }
    }
}
Order { Id = 42, Customer = Ada, Total = 99.50 }
Product { Sku = A-1, Price = 5 }

Three projects: the generator (gen/), an app that uses it (app/) and a small test harness (tests/).

Step 1: The Generator Project (netstandard2.0)

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
  </ItemGroup>

  <ItemGroup>
    <AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
    <AdditionalFiles Include="AnalyzerReleases.Unshipped.md" />
  </ItemGroup>

</Project>
  • netstandard2.0 is not optional. Visual Studio runs the compiler on .NET Framework, so the generator must load there. A net10.0 generator still built and ran from the dotnet CLI, but the analyzers warn: warning RS1041: This compiler extension should not be implemented in an assembly with target framework ‘.NET 10.0’. References to other target frameworks will cause the compiler to behave unpredictably.
  • Microsoft.CodeAnalysis.CSharp 4.14.0: the version decides which compilers can load your generator (see CS9057 below). Don’t simply take the latest.
  • EnforceExtendedAnalyzerRules turns on the RS1xxx rules that catch generator mistakes. IsRoslynComponent marks the project as a compiler component for the IDE.
  • PrivateAssets="all" keeps Roslyn from flowing to projects that consume the generator.

Step 2: The Incremental Generator

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

namespace AutoToString.Generator;

[Generator]
public sealed class AutoToStringGenerator : IIncrementalGenerator
{
    private const string AttributeName = "AutoToString.AutoToStringAttribute";

    private static readonly DiagnosticDescriptor NotPartial = new(
        id: "ATS001",
        title: "Type must be partial",
        messageFormat: "'{0}' has [AutoToString] but is not partial, so no ToString() was generated",
        category: "AutoToString",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true);

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 1. The attribute itself is generated, so users need no extra package
        context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
            "AutoToStringAttribute.g.cs",
            """
            namespace AutoToString
            {
                [System.AttributeUsage(System.AttributeTargets.Class)]
                internal sealed class AutoToStringAttribute : System.Attribute { }
            }
            """));

        // 2. Only classes with the attribute; ForAttributeWithMetadataName is the fast path
        var models = context.SyntaxProvider.ForAttributeWithMetadataName(
            AttributeName,
            predicate: static (node, _) => node is ClassDeclarationSyntax,
            transform: static (ctx, _) => ToModel(ctx));

        // 3. Generate (or report a diagnostic) per class
        context.RegisterSourceOutput(models, static (spc, model) =>
        {
            if (!model.IsPartial)
            {
                spc.ReportDiagnostic(Diagnostic.Create(NotPartial, model.Location?.ToLocation(), model.Name));
                return;
            }
            spc.AddSource($"{model.FullName}.ToString.g.cs", SourceText.From(Render(model), Encoding.UTF8));
        });
    }

    private static ClassModel ToModel(GeneratorAttributeSyntaxContext ctx)
    {
        var symbol = (INamedTypeSymbol)ctx.TargetSymbol;
        var syntax = (ClassDeclarationSyntax)ctx.TargetNode;
        var properties = symbol.GetMembers()
            .OfType<IPropertySymbol>()
            .Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic && p.GetMethod is not null)
            .Select(p => p.Name)
            .ToImmutableArray();

        return new ClassModel(
            Namespace: symbol.ContainingNamespace.IsGlobalNamespace ? null : symbol.ContainingNamespace.ToDisplayString(),
            Name: symbol.Name,
            FullName: symbol.ToDisplayString(),
            IsPartial: syntax.Modifiers.Any(m => m.Text == "partial"),
            Properties: new EquatableArray<string>(properties),
            Location: LocationInfo.From(syntax.Identifier.GetLocation()));
    }

    private static string Render(ClassModel m)
    {
        var parts = string.Join(", ", m.Properties.Select(p => p + " = {" + p + "}"));
        var sb = new StringBuilder("// <auto-generated/>\n");
        if (m.Namespace is not null)
            sb.Append("namespace ").Append(m.Namespace).Append(";\n\n");
        sb.Append("partial class ").Append(m.Name).Append("\n{\n")
          .Append("    public override string ToString() => $\"").Append(m.Name)
          .Append(" {{ ").Append(parts).Append(" }}\";\n}\n");
        return sb.ToString();
    }
}

The pipeline has three stages:

  1. RegisterPostInitializationOutput adds the attribute itself, so users don’t need a second package for it.
  2. ForAttributeWithMetadataName finds only the classes that carry the attribute. It’s the fastest way to select targets, much faster than inspecting every syntax node in CreateSyntaxProvider. The transform turns each class into a small model.
  3. RegisterSourceOutput writes one file per class, or reports a diagnostic when the class isn’t partial.

Two details in Render. The generated file must declare partial class in the same namespace (namespace Shop; when there is one). And literal braces inside the generated interpolated string are written as {{/}}. My first version built the code with a C# raw string and got that escaping wrong.

Step 3: The Model Must Be Equatable (or Caching Silently Stops)

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace AutoToString.Generator;

// Everything the output depends on, and nothing else: no ISymbol, no SyntaxNode, no Location.
// Value equality is what lets the incremental pipeline skip unchanged classes.
internal readonly record struct ClassModel(
    string? Namespace,
    string Name,
    string FullName,
    bool IsPartial,
    EquatableArray<string> Properties,
    LocationInfo? Location);

// ImmutableArray<T> compares by reference, which would make every model "changed" on every keystroke.
internal readonly struct EquatableArray<T>(ImmutableArray<T> items) : IEquatable<EquatableArray<T>>, IEnumerable<T>
    where T : IEquatable<T>
{
    private readonly ImmutableArray<T> _items = items;
    public bool Equals(EquatableArray<T> other) => _items.AsSpan().SequenceEqual(other._items.AsSpan());
    public override bool Equals(object? obj) => obj is EquatableArray<T> other && Equals(other);
    public override int GetHashCode() => _items.Aggregate(17, (h, x) => h * 31 + x.GetHashCode());
    public IEnumerator<T> GetEnumerator() => ((IEnumerable<T>)_items).GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

// Location holds a SyntaxTree reference; store the parts instead.
internal readonly record struct LocationInfo(string FilePath, TextSpan Span, LinePositionSpan LineSpan)
{
    public Location ToLocation() => Location.Create(FilePath, Span, LineSpan);
    public static LocationInfo? From(Location location) =>
        location.SourceTree is null ? null : new LocationInfo(location.SourceTree.FilePath, location.SourceSpan, location.GetLineSpan().Span);
}

The incremental pipeline compares each stage’s output with the previous run and skips the rest of the work when it is equal. That only works if the model has value equality and doesn’t hold Roslyn objects (ISymbol, SyntaxNode, Location, Compilation). ImmutableArray<T> looks harmless, but it compares by reference. The test harness below shows the difference.

CS0518: IsExternalInit is not defined

Records are the natural way to write such a model. But netstandard2.0 lacks a type they need:

Models.cs(14,13): error CS0518: Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported

The fix is a one-line polyfill in the generator project (the compiler only checks that the type exists):

// netstandard2.0 doesn't have this type; records and init accessors need it.
namespace System.Runtime.CompilerServices
{
    internal static class IsExternalInit { }
}

RS2008: Enable analyzer release tracking

Once the generator reports its own diagnostic (ATS001), the build warns RS2008: Enable analyzer release tracking for the analyzer project containing rule ‘ATS001’. Add two files and list them as AdditionalFiles (already in the .csproj above):

; Unshipped analyzer release

### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
ATS001 | AutoToString | Error | Type with [AutoToString] must be partial

AnalyzerReleases.Shipped.md starts with just a ; Shipped analyzer releases comment. After that the generator built with 0 warnings.

Step 4: Reference the Generator from Your App

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\gen\AutoToString.Generator.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false" />
  </ItemGroup>

</Project>

EmitCompilerGeneratedFiles writes the generated files to disk, so you can read them without an IDE:

obj/Debug/net10.0/generated/AutoToString.Generator/AutoToString.Generator.AutoToStringGenerator/AutoToStringAttribute.g.cs
obj/Debug/net10.0/generated/AutoToString.Generator/AutoToString.Generator.AutoToStringGenerator/Order.ToString.g.cs
obj/Debug/net10.0/generated/AutoToString.Generator/AutoToString.Generator.AutoToStringGenerator/Shop.Product.ToString.g.cs
// <auto-generated/>
namespace Shop;

partial class Product
{
    public override string ToString() => $"Product {{ Sku = {Sku}, Price = {Price} }}";
}

The Errors You’ll Hit (and What They Really Mean)

Using a built-in generator and getting CS0260: Missing partial modifier on declaration of type? That one has its own post: CS0260 causes and fixes (GeneratedRegex, LoggerMessage, ObservableProperty, nested types).

CS0616: ‘AutoToString’ is not an attribute class

This is what you get with a plain <ProjectReference Include="..\gen\AutoToString.Generator.csproj" />, without OutputItemType="Analyzer":

Program.cs(18,6): error CS0616: 'AutoToString' is not an attribute class
Program.cs(7,2): error CS0616: 'AutoToString' is not an attribute class

The message points you the wrong way. The generator never ran, so the attribute was never generated, and AutoToString resolved to the generator’s namespace, which was now referenced as an ordinary assembly. If a generated type “doesn’t exist”, check the reference first.

CS9057: The analyzer references a newer version of the compiler

I built the same generator against Microsoft.CodeAnalysis.CSharp 5.0.0 and the app with the .NET 9 SDK (9.0.312, which runs Roslyn 4.14):

CSC : warning CS9057: Analyzer assembly 'D:\MyWebsites\CSharp-Coder\SEO-Growth\drafts\code\srcgen\v9\gen\bin\Debug\netstandard2.0\AutoToString.Generator.dll' cannot be used because it references version '5.0.0.0' of the compiler, which is newer than the currently running version '4.14.0.0'.
Program.cs(1,7): error CS0246: The type or namespace name 'AutoToString' could not be found (are you missing a using directive or an assembly reference?)
Program.cs(18,6): error CS0246: The type or namespace name 'AutoToStringAttribute' could not be found (are you missing a using directive or an assembly reference?)
Program.cs(7,2): error CS0246: The type or namespace name 'AutoToStringAttribute' could not be found (are you missing a using directive or an assembly reference?)
Program.cs(7,2): error CS0246: The type or namespace name 'AutoToString' could not be found (are you missing a using directive or an assembly reference?)
Program.cs(18,6): error CS0246: The type or namespace name 'AutoToString' could not be found (are you missing a using directive or an assembly reference?)

The real problem is the warning on the first line. The compiler refuses to load a generator built for a newer Roslyn, skips it, and every use of the generated attribute then fails as CS0246. This is the classic “works on my machine, fails in CI” generator bug: your machine has the .NET 10 SDK, the build agent or a teammate’s Visual Studio has an older compiler. Reference the oldest Roslyn version you need to support. With 4.14.0 the same app built and ran on SDK 9:

Order { Id = 42, Customer = Ada, Total = 99.50 }
Product { Sku = A-1, Price = 5 }

ATS001: our own diagnostic for a missing partial

Remove partial from Order and the generator reports its own error instead of emitting code that wouldn’t compile:

Program.cs(8,14): error ATS001: 'Order' has [AutoToString] but is not partial, so no ToString() was generated

Reporting a clear diagnostic with its own ID is better than leaving users to decode a compiler error on generated code they can’t see.

Testing a Generator Without Building an App

CSharpGeneratorDriver runs the generator in-process on a source string, which is how unit tests for generators work. This harness prints the generated files and diagnostics, then runs again after an unrelated change and asks the driver whether it reused the cached output:

using AutoToString.Generator;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;

var source = """
    using AutoToString;
    namespace Shop;
    [AutoToString] public partial class Order { public int Id { get; set; } }
    [AutoToString] public class Invoice { public int Number { get; set; } }
    """;

var compilation = CSharpCompilation.Create("Tests",
    [CSharpSyntaxTree.ParseText(source)],
    [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
    new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

GeneratorDriver driver = CSharpGeneratorDriver.Create(
    [new AutoToStringGenerator().AsSourceGenerator()],
    driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true));

driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out var diagnostics);

foreach (var tree in output.SyntaxTrees.Skip(1))
    Console.WriteLine($"--- {Path.GetFileName(tree.FilePath)}\n{tree}");
foreach (var d in diagnostics)
    Console.WriteLine($"DIAG {d}");

// Run again on an edited compilation: an unrelated file changes, the model of Order must come from the cache
var edited = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText("class Unrelated { }"));
driver = driver.RunGenerators(edited);
var steps = driver.GetRunResult().Results[0].TrackedOutputSteps;
foreach (var (name, runs) in steps)
    foreach (var run in runs)
        Console.WriteLine($"2nd run, {name}: {string.Join(", ", run.Outputs.Select(o => o.Reason))}");
--- AutoToStringAttribute.g.cs
namespace AutoToString
{
    [System.AttributeUsage(System.AttributeTargets.Class)]
    internal sealed class AutoToStringAttribute : System.Attribute { }
}
--- Shop.Order.ToString.g.cs
// <auto-generated/>
namespace Shop;

partial class Order
{
    public override string ToString() => $"Order {{ Id = {Id} }}";
}

DIAG (4,29): error ATS001: 'Invoice' has [AutoToString] but is not partial, so no ToString() was generated
2nd run, SourceOutput: Cached
2nd run, SourceOutput: Cached

Both outputs were Cached on the second run. Then I changed one line in the generator, storing Properties as a plain ImmutableArray<string> instead of EquatableArray<string>, and ran the same test:

2nd run, SourceOutput: Modified
2nd run, SourceOutput: Modified

Same generated code, no warning, but every edit anywhere in the project now regenerates every file. In a large solution that’s the difference between a responsive IDE and one that lags on each keystroke. Put an assertion like this in your generator’s tests.

Checklist

RuleWhy
Target netstandard2.0Visual Studio loads generators on .NET Framework (RS1041 otherwise)
Reference the oldest Roslyn you supportNewer than the running compiler → CS9057, generator skipped
OutputItemType="Analyzer" ReferenceOutputAssembly="false"Otherwise the generator doesn’t run (CS0616/CS0246)
IIncrementalGenerator + ForAttributeWithMetadataNameA class implementing the old ISourceGenerator failed my build with error RS1042 (“Implementations of this interface are not allowed”)
Equatable models, no symbols or syntax in themOtherwise caching silently turns off (Modified vs Cached)
Generate the marker attribute in post-initializationUsers need nothing but the generator reference
Report your own diagnosticsClear errors instead of broken generated code
EmitCompilerGeneratedFiles while developingSee exactly what was generated under obj/

FAQ

Do source generators work with Native AOT? Yes. The output is ordinary C# compiled with the rest of your code, which is why System.Text.Json, logging and regex in .NET use generators to avoid runtime reflection.

Can a generator modify existing code? No. It can only add files. That’s why the target class must be partial.

Generator vs T4 templates? T4 runs at design time on files you choose. A source generator runs on every compilation and sees the semantic model (types, attributes, symbols), so it stays in sync with your code automatically.

Related: Compile C# at runtime with Roslyn · System.Text.Json (uses a source generator for AOT) · Interface vs abstract class

References: Incremental generators (Roslyn docs) · Incremental generators cookbook · Source generators overview (Microsoft Learn)

This article is part of the C# tutorials guide (language features, patterns, performance and testing).

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *