C# & .NET (C# 14, .NET 10 LTS) Module 18 – Reflection, attributes and source generators
Writing an incremental source generator
Build a C# incremental source generator step by step: the project, the pipeline, models that cache, diagnostics, tests with the driver, and a NuGet package.
What you will learn
- Build an IIncrementalGenerator that finds an attribute with ForAttributeWithMetadataName and writes a file per type
- Set up a generator project for netstandard2.0 and reference it from a project that runs it
- Design value-equatable models that keep a generator incremental, and prove the caching with tracked steps
- Test a generator with CSharpGeneratorDriver, and report problems as diagnostics instead of throwing
- Package a generator under analyzers/dotnet/cs, and choose the Roslyn version it references
Before you start
What you’ll learn · Before you start
What you will learn
- Build an IIncrementalGenerator that finds an attribute with ForAttributeWithMetadataName and writes a file per type
- Set up a generator project for netstandard2.0 and reference it from a project that runs it
- Design value-equatable models that keep a generator incremental, and prove the caching with tracked steps
- Test a generator with CSharpGeneratorDriver, and report problems as diagnostics instead of throwing
- Package a generator under analyzers/dotnet/cs, and choose the Roslyn version it references
Before you start
On this page
A CSV writer that works by reflection, like the one of the reflection lesson, allocated 512 bytes for every row it
wrote, because it looked up each property and boxed each value while the program ran. A source generator can write
the same row code for each type while the program is built: the generated ToCsvRow() of this lesson allocated 152
bytes a row, for the strings of the row alone.
You will write that generator, an incremental one, as a small library that the compiler loads: its project, the pipeline it sets up, the code it writes and the run that uses it. Under every program is the output it gave on .NET 10.
A generator is a netstandard2.0 library that the compiler loads
An incremental source generator: A source generator that implements IIncrementalGenerator: its Initialize method describes a pipeline of steps, and the compiler reruns only the steps whose input changed since the last run. is a class that implements
IIncrementalGenerator and carries [Generator], compiled into an ordinary class library. The compiler loads that
library into every host it runs in, the command line and each IDE, so the project targets netstandard2.0, and it
references the Roslyn package, Microsoft.CodeAnalysis.CSharp 5.9.0, with PrivateAssets="all", because the compiler
already brings Roslyn at run time:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- The compiler loads generators into every kind of host, so they target netstandard2.0. -->
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<!-- The rules for code that runs inside the compiler, and IDE support for debugging the generator. -->
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<!-- Packing: the .dll goes to analyzers/dotnet/cs, not lib/, and users get it as a build-time tool only. -->
<Version>1.0.0</Version>
<IncludeBuildOutput>false</IncludeBuildOutput>
<DevelopmentDependency>true</DevelopmentDependency>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
</PropertyGroup>
<ItemGroup>
<!-- The compiler supplies Roslyn at run time, so the package is not a dependency of the generator's users. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.9.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
<ItemGroup>
<!-- The checks in ../Checks run the generator on code of their own and may see its internals. -->
<InternalsVisibleTo Include="Checks" />
</ItemGroup>
<ItemGroup>
<!-- The diagnostics this component reports, release by release (rule RS2008 asks for them). -->
<AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
<AdditionalFiles Include="AnalyzerReleases.Unshipped.md" />
</ItemGroup>
</Project> EnforceExtendedAnalyzerRules switches on the rules for code that runs inside the compiler, and the two release files
list the one diagnostic the generator reports. The project that uses the generator, App, references it with
OutputItemType="Analyzer" and ReferenceOutputAssembly="false": the compiler runs it, and the program does not link
it. Step through what one build of App does with the two projects:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The reference comes first. App.csproj references CsvGen.csproj with OutputItemType="Analyzer", so MSBuild builds CsvGen first, against the Roslyn package it restored.
- Handed to the compiler. The reference does not make CsvGen.dll a library of App: ReferenceOutputAssembly="false" keeps it out, and the compiler receives it as an analyzer to load.
- Loaded inside the compiler. The compiler loads the assembly and finds the class marked [Generator]. It creates CsvRowGenerator and calls its Initialize method once.
- The pipeline is set up. Initialize only describes work: add the marker attribute, find types with [CsvRow], make a model of each, write a file per model. Nothing runs yet.
- The pipeline runs. The compiler runs the pipeline over App's code. It finds Booking and Refund, and the generator adds Booking.CsvRow.g.cs and Refund.CsvRow.g.cs.
- One assembly. The compiler compiles App's code and the generated files into App.dll. CsvGen.dll is not part of the program: at run time ToCsvRow is plain code of App.
One detail of netstandard2.0 catches nearly every first generator: it predates records and init, which the
generator’s model uses, so the project adds a one-line stand-in for the type the compiler needs, in IsExternalInit.cs.
Checkpoint
Check: The generator project and its reference
Initialize sets up a pipeline that turns an attribute into a file
A generator does its work in Initialize, and that method only describes it: a chain of steps that the compiler runs
whenever the code changes, caching what each step produced. CsvRowGenerator uses the three calls almost every
generator needs:
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace CsvGen;
/// <summary>Writes CsvHeader and ToCsvRow() for every partial class or record marked [CsvRow].</summary>
[Generator(LanguageNames.CSharp)]
public sealed class CsvRowGenerator : IIncrementalGenerator
{
internal const string Support = """
// <auto-generated/>
#nullable enable
namespace CsvGen
{
[global::Microsoft.CodeAnalysis.EmbeddedAttribute]
[global::System.AttributeUsage(global::System.AttributeTargets.Class)]
internal sealed class CsvRowAttribute : global::System.Attribute
{
}
[global::Microsoft.CodeAnalysis.EmbeddedAttribute]
internal static class CsvText
{
private static readonly char[] Special = { ',', '"', '\n', '\r' };
public static string Field(string? value) =>
value is null ? "" :
value.IndexOfAny(Special) < 0 ? value :
"\"" + value.Replace("\"", "\"\"") + "\"";
}
}
""";
internal static readonly DiagnosticDescriptor NotPartial = new(
id: "CSVGEN001",
title: "A [CsvRow] type must be partial",
messageFormat: "'{0}' is marked [CsvRow] but is not partial, so no CSV code was generated for it",
category: "CsvGen",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 1. Code that every user of the generator needs, added once, before anything else runs.
context.RegisterPostInitializationOutput(static ctx =>
{
ctx.AddEmbeddedAttributeDefinition();
ctx.AddSource("CsvRow.Support.g.cs", SourceText.From(Support, Encoding.UTF8));
});
// 2. Every type with [CsvRow], turned into a model of plain, comparable values.
IncrementalValuesProvider<RowModel> rows = context.SyntaxProvider
.ForAttributeWithMetadataName(
"CsvGen.CsvRowAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax or RecordDeclarationSyntax,
transform: static (ctx, _) => RowModel.From((INamedTypeSymbol)ctx.TargetSymbol, (TypeDeclarationSyntax)ctx.TargetNode))
.WithTrackingName("Rows");
// 3. One source file per model.
context.RegisterSourceOutput(rows, static (spc, row) =>
{
if (!row.IsPartial)
{
spc.ReportDiagnostic(Diagnostic.Create(NotPartial, row.Where.ToLocation(), row.Name));
return;
}
spc.AddSource($"{row.Name}.CsvRow.g.cs", SourceText.From(row.Render(), Encoding.UTF8));
});
}
}
/// <summary>What the generator needs to know about one type: strings, booleans and value types only.</summary>
internal sealed record RowModel(string Namespace, string Name, string Keyword, bool IsPartial, EquatableArray<Column> Columns, Place Where)
{
public static RowModel From(INamedTypeSymbol type, TypeDeclarationSyntax syntax)
{
var columns = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic && !p.IsIndexer && p.GetMethod is not null)
.Select(p => new Column(p.Name, p.Type.SpecialType == SpecialType.System_String ? Format.Text
: p.Type.AllInterfaces.Any(i => i.ToDisplayString() == "System.IFormattable") ? Format.Invariant : Format.Plain))
.ToImmutableArray();
return new RowModel(
type.ContainingNamespace.IsGlobalNamespace ? "" : type.ContainingNamespace.ToDisplayString(),
type.Name,
type.IsRecord ? "record" : "class",
syntax.Modifiers.Any(SyntaxKind.PartialKeyword),
new EquatableArray<Column>(columns),
Place.Of(syntax.Identifier.GetLocation()));
}
public string Render()
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
if (Namespace.Length > 0) sb.AppendLine($"namespace {Namespace};");
sb.AppendLine();
sb.AppendLine($"partial {Keyword} {Name}");
sb.AppendLine("{");
sb.AppendLine($" public static string CsvHeader => \"{string.Join(",", Columns.Select(c => c.Name))}\";");
sb.AppendLine();
sb.AppendLine(" public string ToCsvRow() => string.Join(\",\",");
sb.AppendLine(string.Join(",\n", Columns.Select(c => $" global::CsvGen.CsvText.Field({c.Expression})")) + ");");
sb.AppendLine("}");
return sb.ToString();
}
}
internal enum Format
{
Text,
Invariant,
Plain,
}
internal sealed record Column(string Name, Format Format)
{
public string Expression => Format switch
{
Format.Text => Name,
Format.Invariant => $"{Name}.ToString(null, global::System.Globalization.CultureInfo.InvariantCulture)",
_ => $"{Name}?.ToString()",
};
}
/// <summary>A source location as values, which compare equal across compilations, unlike a Location object.</summary>
internal readonly record struct Place(string Path, TextSpan Span, LinePositionSpan Lines)
{
public static Place Of(Location location) => new(location.SourceTree?.FilePath ?? "", location.SourceSpan, location.GetLineSpan().Span);
public Location ToLocation() => Location.Create(Path, Span, Lines);
} RegisterPostInitializationOutput adds the marker attribute: An attribute with no behaviour of its own that marks code for a tool, such as [CsvRow] for a source generator. The generator usually adds its definition to the user's project itself. [CsvRow] and a
small helper, marked [Embedded] as the Roslyn cookbook advises, so they stay private to each project.
ForAttributeWithMetadataName finds every type that carries the attribute, and its transform turns each one into a
RowModel of strings and values. RegisterSourceOutput writes one file per model. Step through the pipeline, then a
second run after an edit:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Before anything else. RegisterPostInitializationOutput adds the [CsvRow] attribute and the CsvText helper once, so the user's code can use the attribute that the rest of the pipeline looks for.
- Find the marked types. ForAttributeWithMetadataName asks the compiler for every syntax node with the attribute CsvGen.CsvRowAttribute. The compiler keeps an index of attributes, so most of the code is never looked at.
- Make a model. For each class or record found, the transform reads the type's name, namespace and public properties and keeps them in a RowModel of strings and values.
- Write a file. RegisterSourceOutput turns each model into C# text and adds it as Booking.CsvRow.g.cs and Refund.CsvRow.g.cs, or reports CSVGEN001 for a type that is not partial.
- You edit another file. On the next keystroke the compiler runs the pipeline again. The transform runs again for the marked types, and the new RowModel of Booking is compared with the old one.
- Equal, so nothing is written. The models are equal, so the step that writes files is skipped and the old Booking.CsvRow.g.cs is reused. That comparison is why the model must compare by value.
The comparison in the last step is the heart of an incremental generator: the compiler reuses a step’s old output
when the new input equals the old, which is why the model holds only values. The session builds App and shows what
the generator wrote for Booking:
from session import run
# network: true. CsvGen references the Roslyn package Microsoft.CodeAnalysis.CSharp, which restore downloads from
# nuget.org into the session's own, empty home folder. Building App builds CsvGen first and runs it as a generator;
# App keeps the generated files under obj/, and the second command shows the one written for Booking.
run("""
cd csvgen/App
dotnet build -v q > build.log
cat obj/Debug/net10.0/generated/CsvGen/CsvGen.CsvRowGenerator/Booking.CsvRow.g.cs
dotnet run --no-build
""", network=True) Output
$ cd csvgen/App
$ dotnet build -v q > build.log
$ cat obj/Debug/net10.0/generated/CsvGen/CsvGen.CsvRowGenerator/Booking.CsvRow.g.cs
// <auto-generated/>
#nullable enable
partial record Booking
{
public static string CsvHeader => "Pnr,Train,Coach,Passengers,Fare";
public string ToCsvRow() => string.Join(",",
global::CsvGen.CsvText.Field(Pnr),
global::CsvGen.CsvText.Field(Train.ToString(null, global::System.Globalization.CultureInfo.InvariantCulture)),
global::CsvGen.CsvText.Field(Coach),
global::CsvGen.CsvText.Field(Passengers.ToString(null, global::System.Globalization.CultureInfo.InvariantCulture)),
global::CsvGen.CsvText.Field(Fare.ToString(null, global::System.Globalization.CultureInfo.InvariantCulture)));
}
$ dotnet run --no-build
Pnr,Train,Coach,Passengers,Fare
4521873690,12951,3A,2,1715.25
8834019265,22691,SL,4,1890
Pnr,Amount,Reason
4521873690,857.5,"seat, not given"
reflection writes the same row: True
generated ToCsvRow(): 152 bytes a row
reflection writer: 512 bytes a row
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 build_session.py
The generated ToCsvRow() formats numbers in the invariant culture and quotes only fields that need it, as the refund
with a comma shows, and it wrote the same row as the reflection-based writer. The last two lines measure the
difference at run time: 152 bytes a row for the generated code, the row’s own strings, against 512 for reflection.
Key idea
A generator’s Initialize describes a pipeline: find the marked types, turn each into a model of plain values, and write a file per model. The compiler runs it on every change and skips the steps whose input has not changed.
Checkpoint
Check: The incremental pipeline of Initialize
Caching that works: equatable models, proved with tracked steps Pro: opens with a Pro, Premium or Ultimate pass
A generator can produce correct code and still slow every keystroke, if its pipeline never caches. This part shows what may go into a model, and measures the difference one wrong choice makes.
In this part:
- What may go into a model, and why symbols may not
- One edit, two generators: Cached against Modified, measured
- Collections in a model, and the equality that keeps them cached
1 runnable example (Python) · about 3 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Diagnostics instead of exceptions, and tests that run the generator Pro: opens with a Pro, Premium or Ultimate pass
A generator meets every kind of code a user can write, so it must fail politely. This part reproduces what an exception does, reports a problem the right way, and tests both with the driver.
In this part:
- A generator that throws loses its output, reproduced
- Reporting CSVGEN001 with a location made of values
- Tests that run the generator on code of their own
1 runnable example (Python) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Packaging a generator, the Roslyn version to reference, and a lab Pro: opens with a Pro, Premium or Ultimate pass
A generator reaches other teams as a NuGet package with a layout of its own. This part packs CsvGen, uses it from a
package, explains which Roslyn version to build against, and ends with a lab.
In this part:
- The package layout: analyzers/dotnet/cs, and nothing in lib
- Which Roslyn version to reference
- Lab: an [AutoToString] generator
2 runnable examples (Python) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
Practice that adapts to you
Practise this lesson
8 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What makes a source generator incremental?
Its Initialize describes a pipeline of steps instead of doing the work at once. The compiler runs the pipeline on every change, compares each step’s new output with the old one, and skips every later step when they are equal.
-
Mid to senior
Since your team added its own source generator, typing in a large solution has become slow, and a trace shows the generator's output being rebuilt on every keystroke for hundreds of types. What do you look at first, and how do you make the generator really incremental?
-
Mid to senior
A build on the CI server shows warning CS8785 from your generator, followed by errors that a generated method does not exist. On your machine everything builds. What happened, and how do you make the generator fail well?
-
Senior to staff
You must ship a source generator to 40 teams that use different SDK and IDE versions. How do you package and version it, which Roslyn version do you build against, and what do you test before every release?
Key takeaways
- A generator is a netstandard2.0 library that references the Roslyn package privately, and a project runs it through a ProjectReference with OutputItemType=“Analyzer”.
- Initialize sets up a pipeline: post-initialization output for the marker attribute, ForAttributeWithMetadataName to find the marked types and make a model of each, and RegisterSourceOutput to write a file per model.
- The compiler reuses a step’s output while its input compares equal, so models hold plain values only.
Terms in this lesson
- incremental source generator
- A source generator that implements IIncrementalGenerator: its Initialize method describes a pipeline of steps, and the compiler reruns only the steps whose input changed since the last run.
- marker attribute
- An attribute with no behaviour of its own that marks code for a tool, such as [CsvRow] for a source generator. The generator usually adds its definition to the user's project itself.
References
- Incremental generators (Roslyn design notes) (.NET Foundation (dotnet/roslyn))
- Incremental generators cookbook (.NET Foundation (dotnet/roslyn))
- Source generators (Roslyn design notes) (.NET Foundation (dotnet/roslyn))
- The .NET Compiler Platform SDK (Microsoft Learn)
- GeneratorDriverOptions struct (Microsoft Learn)
- .NET Compiler Platform analyzer formats for NuGet (Microsoft Learn)
- Supported Roslyn package version mappings (Microsoft Learn)
- Microsoft.CodeAnalysis.CSharp package (NuGet Gallery)
- Analyzer release tracking (RS2008) (.NET Foundation (dotnet/roslyn))
Report a problem with this lesson
Kept only in this browser. Your Learn progress