Source Generators & Analyzers
Key Points
- Source generators emit C# code at compile time. The compiler treats generated code as part of your project — it shows up in IntelliSense, can be debugged, and ships in your binaries.
- Analyzers read code and emit diagnostics (warnings/errors) without changing it. Code-fix providers suggest automated fixes.
- The modern API is
IIncrementalGenerator(Microsoft.CodeAnalysis.CSharp4.0+). OldISourceGeneratoris legacy — incremental generators run faster and cache properly. - Microsoft ships several first-party generators that replace runtime reflection with compile-time code:
[LoggerMessage],JsonSerializerContext,[GeneratedRegex],RegexGenerator,Microsoft.Extensions.Configuration.Bindersource-gen, EF Core compiled models. - For the build to discover a generator, it must be referenced as an analyzer (special
<PackageReference>form), not a normal library. - Generators are C# only at the input side (they read C# syntax and semantic info via Roslyn). The output is also C# (you write text; Roslyn compiles it).
- Analyzers run on every keystroke in the IDE; performance matters. Cache, prefer pull-based filtering, avoid LINQ on
SyntaxNodeenumerations.
Concepts (deep dive)
Where source generators fit
┌─────────────────────────────────────────────┐
│ Compilation pipeline │
│ │
│ Source files ─┐ │
│ ├──► Roslyn parser ──► Syntax │
│ References ───┘ │ │
│ ▼ │
│ Semantic model │
│ │ │
│ ┌───────────────────────┴───────────┐ │
│ ▼ ▼ │
│ Analyzers Source generators │
│ (read-only, (emit new files, │
│ emit diagnostics) become part of comp.)│
│ │ │
│ ▼ │
│ Re-included syntax │
│ │ │
│ ▼ │
│ Codegen → IL │
└─────────────────────────────────────────────┘
Generators run during compilation. The output is integrated into the same compilation that produced it; downstream phases see the generated code as if you'd written it.
IIncrementalGenerator 101
Incremental generators are pull-based and cached. You declare a pipeline of transformations from SyntaxNode data → final source. Each stage caches its output keyed on its inputs. If your inputs don't change, the stage doesn't re-run.
[Generator(LanguageNames.CSharp)]
public class HelloGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 1. Find candidate types: classes annotated with [Hello]
IncrementalValuesProvider<INamedTypeSymbol> targets = context.SyntaxProvider
.ForAttributeWithMetadataName(
"MyApp.HelloAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, _) => (INamedTypeSymbol)ctx.TargetSymbol);
// 2. For each candidate, emit a partial method.
context.RegisterSourceOutput(targets, static (spc, type) =>
{
var ns = type.ContainingNamespace.ToDisplayString();
var name = type.Name;
var source = $$"""
namespace {{ns}};
partial class {{name}}
{
public string SayHello() => "Hello from {{name}}!";
}
""";
spc.AddSource($"{name}_Hello.g.cs", source);
});
}
}
Notes:
staticlambdas avoid capturing context (which would defeat caching).ForAttributeWithMetadataNameis the fast path for "find all things annotated with X" — the compiler indexes attributes for you.- The output filename should end
.g.csso IDEs and tools recognize it as generated.
Hosting a generator from a project
In a generator project (typically targeting netstandard2.0 for compatibility):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" />
</ItemGroup>
</Project>
In the consumer project:
<ItemGroup>
<ProjectReference Include="../HelloGenerator/HelloGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
The OutputItemType="Analyzer" is what tells the compiler this is a generator. ReferenceOutputAssembly="false" says "don't add it as a runtime reference; it's compile-time only."
Built-in generators that replace reflection
[LoggerMessage]
public partial class OrderService
{
private readonly ILogger _logger;
[LoggerMessage(EventId = 100, Level = LogLevel.Warning,
Message = "Order {OrderId} took {ElapsedMs} ms")]
public partial void SlowOrder(int orderId, long elapsedMs);
}
The generator emits the partial method body — a no-allocation, no-boxing logging call. ~15–20% faster than _logger.LogWarning("Order {OrderId} ...", orderId, elapsedMs). Use this for any log message in a hot path.
JsonSerializerContext
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
internal partial class AppJsonContext : JsonSerializerContext { }
string json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
Two modes: - Metadata mode — generator emits type metadata; runtime serializer uses it (smaller binary, slightly slower than reflection-free). - Serialization mode — generator emits direct Utf8JsonWriter calls (largest binary, fastest, AOT-safe).
Pick mode with [JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Default)].
[GeneratedRegex]
public partial class IpParser
{
[GeneratedRegex(@"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", RegexOptions.Compiled)]
private static partial Regex IpRegex();
}
Generator emits the FA (finite automaton) as compile-time C#. No runtime regex compilation; AOT-safe. C# 13 added support for partial properties in addition to methods.
Microsoft.Extensions.Configuration.Binder
Configuration.Get<T>() traditionally uses reflection. The source generator (since .NET 8) emits the binding code at compile time:
<PropertyGroup>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
Net effect: Configuration.Get<MyOptions>() becomes a hand-rolled deserializer at compile time, AOT-safe.
EF Core compiled models
Generates compiled metadata for the DbContext — eliminates startup reflection cost. Improves cold-start dramatically.
Analyzers: enforcing policy
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class NoDateTimeNowAnalyzer : DiagnosticAnalyzer
{
public static readonly DiagnosticDescriptor Rule = new(
id: "ABC001",
title: "Don't use DateTime.Now",
messageFormat: "Use DateTimeOffset.UtcNow or TimeProvider instead",
category: "Style",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
=> ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.RegisterOperationAction(AnalyzeAccess, OperationKind.PropertyReference);
}
private static void AnalyzeAccess(OperationAnalysisContext ctx)
{
var op = (IPropertyReferenceOperation)ctx.Operation;
if (op.Property.Name == "Now" &&
op.Property.ContainingType.ToDisplayString() == "System.DateTime")
{
ctx.ReportDiagnostic(Diagnostic.Create(Rule, op.Syntax.GetLocation()));
}
}
}
Pair with a CodeFixProvider that proposes the fix.
EditorConfig integration
Enforce diagnostic severity per project / per folder:
# .editorconfig
[*.cs]
dotnet_diagnostic.ABC001.severity = error
dotnet_diagnostic.CA1031.severity = none # turn off "do not catch general exception"
This is the modern way to centralize analyzer policy. Combined with Directory.Build.props's <AnalysisLevel>latest-recommended</AnalysisLevel>, you get a strong default with per-folder tuning.
How it works under the hood
Roslyn's compilation pipeline runs in phases:
Generators see the bound model — they have access to type information, not just syntax. The output of a generator becomes input to a re-bind phase if needed.
The "incremental" in IIncrementalGenerator refers to the dataflow inside Roslyn:
SyntaxProvider.ForAttributeWithMetadataName(...)
│
▼
Per-syntax-node transform (cached)
│
▼
Combine with compilation info (cached)
│
▼
RegisterSourceOutput(...)
Each step's output is cached against its inputs. If you type a single character that doesn't change anything semantic, none of the stages re-run. This is what makes incremental generators usable in IDEs.
💡 Senior insight: the pipeline must be expressible in terms of equatable values. If a stage outputs a non-equatable type (like
INamedTypeSymboldirectly), caching breaks and the generator runs every keystroke. Convert symbols to small DTOs early.
Code: correct vs wrong
❌ Wrong: legacy ISourceGenerator for new generators
[Generator]
public class OldSchool : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context) { }
public void Execute(GeneratorExecutionContext context) { /* runs every keystroke */ }
}
Legacy. Slow. No caching. Don't write new code with this.
✅ Correct: IIncrementalGenerator
[Generator(LanguageNames.CSharp)]
public class New : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var pipeline = context.SyntaxProvider.ForAttributeWithMetadataName(
"MyApp.MarkAttribute",
predicate: static (n, _) => n is ClassDeclarationSyntax,
transform: static (ctx, _) => new ModelDto((INamedTypeSymbol)ctx.TargetSymbol));
context.RegisterSourceOutput(pipeline, static (spc, model) => Emit(spc, model));
}
}
internal record ModelDto(INamedTypeSymbol Type); // wraps for clarity
❌ Wrong: holding INamedTypeSymbol across pipeline stages
var stage1 = ctx.SyntaxProvider.CreateSyntaxProvider(
predicate: ...,
transform: (ctx, _) => (INamedTypeSymbol)ctx.SemanticModel.GetDeclaredSymbol(ctx.Node)!);
// ❌ INamedTypeSymbol equality is per-compilation; caching breaks across runs.
✅ Correct: project to a small equatable DTO early
private record TypeDto(string Namespace, string Name, ImmutableArray<string> PropertyNames);
var stage1 = ctx.SyntaxProvider.CreateSyntaxProvider(
predicate: ...,
transform: (ctx, _) =>
{
var sym = (INamedTypeSymbol)ctx.SemanticModel.GetDeclaredSymbol(ctx.Node)!;
return new TypeDto(
sym.ContainingNamespace.ToDisplayString(),
sym.Name,
sym.GetMembers().OfType<IPropertySymbol>().Select(p => p.Name).ToImmutableArray());
});
record gives value equality automatically; ImmutableArray<T> doesn't have built-in structural equality but works with custom IEqualityComparer.
❌ Wrong: analyzer doing heavy work on every syntax node
context.RegisterSyntaxNodeAction(ctx =>
{
foreach (var member in ctx.Compilation.GetSymbolsWithName(...))
// ❌ scans entire compilation per node
}, SyntaxKind.IdentifierName);
✅ Correct: register at compilation level, cache inside
context.RegisterCompilationStartAction(start =>
{
var precomputed = ExpensiveAnalysisOfCompilation(start.Compilation);
start.RegisterSyntaxNodeAction(ctx => CheckOne(ctx, precomputed), SyntaxKind.IdentifierName);
});
Design patterns for this topic
Pattern 1 — "Replace reflection with a source generator"
- Intent: any pattern that today reflects on user types at startup is a generator candidate.
- When to use it: logging, serialization, DI registration, configuration binding, ORM model building, mappers (e.g., Mapperly).
- Trade-offs: extra build complexity; generators are a real maintenance burden if you write them. Use existing first-party generators when possible.
Pattern 2 — "DTO at the boundary"
- Intent: project Roslyn symbols to plain C# records before pipeline stages so caching works.
- When to use it: every incremental generator with multi-stage pipelines.
Pattern 3 — "Compilation-start once, action many"
- Intent: in analyzers, do expensive setup at compilation start; do per-node checks fast.
- Code sketch: see "correct vs wrong" #4.
Pattern 4 — "Code-fix provider per analyzer rule"
- Intent: make the rule actionable.
- When to use it: any time the fix is mechanical.
- Trade-offs: code-fix providers double the testing surface but pay back hugely in developer happiness.
Pattern 5 — "Severity by EditorConfig, not in the analyzer"
- Intent: decouple policy from analyzer code.
- When to use it: always — let consumers tune severities without forking the analyzer.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Source generators | Compile-time codegen; AOT-safe; no runtime cost | Extra build complexity; debugging takes effort |
| Analyzers | Catch issues at design-time | Wrong rules become bureaucratic noise |
IIncrementalGenerator | Cached, fast in IDE | Subtle caching pitfalls (DTO discipline) |
[LoggerMessage] | Best-in-class structured logging perf | Boilerplate for every log message |
JsonSerializerContext | AOT-safe, fast serialization | Must declare every type |
When to use / when to avoid
- Use a source generator when the alternative is reflection at startup or in a hot path. The build cost is paid once; the runtime cost is paid on every call.
- Use existing first-party generators before writing your own. The maintenance overhead of a custom generator is real.
- Avoid writing your own analyzer for "nice to have" rules — only write an analyzer if the rule is actionable, has a clear fix, and matters enough to fail builds.
- Avoid generators that produce thousands of files — IDE memory becomes a problem.
Interview Q&A
Q1. What's an incremental source generator and why does it matter? An incremental generator (IIncrementalGenerator) declares a pipeline of transformations whose outputs are cached against their inputs. If a keystroke doesn't change anything that affects a stage, the stage doesn't re-run. The legacy ISourceGenerator runs the entire generator on every change — too slow for IDE use.
Q2. How does [LoggerMessage] produce faster logging than _logger.LogWarning(...)? The generator emits a partial method that calls into the logging framework with pre-allocated state objects, skipping boxing and template parsing. The result: ~15–20% lower CPU and zero allocation for value-type parameters.
Q3. Why does JsonSerializerContext matter for AOT? The default JsonSerializer.Serialize<T> path uses reflection — won't survive trimming or AOT. The source generator emits the serialization code at compile time, so trimming can see what's used.
Q4. What's the difference between a generator that targets netstandard2.0 vs one that targets net10.0? Generators run inside the Roslyn compiler, which is itself .NET-runtime hosted. Roslyn lives on a relatively old runtime for compatibility. netstandard2.0 is the safe target for generators; targeting newer frameworks may fail to load in older Roslyn or Visual Studio versions.
Q5. How do you debug a source generator? Set <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> and a <CompilerGeneratedFilesOutputPath> — generated files are written to disk where you can inspect them. Use Debugger.Launch() in the generator and attach during compilation.
Q6. What's ForAttributeWithMetadataName and why prefer it? A specialized fast path for attribute-driven generators. The compiler indexes attributes by metadata name, so this provider only triggers for relevant nodes — much faster than generic CreateSyntaxProvider over SyntaxKind.AttributeList.
Q7. What's a code-fix provider? An optional companion to an analyzer that proposes fixes for the diagnostic. Implements CodeFixProvider. Triggered by Ctrl+. in the IDE.
Q8. How would you enforce "no DateTime.Now, use TimeProvider" across a codebase? Custom analyzer (or use an existing one — there are several). Set dotnet_diagnostic.<id>.severity = error in .editorconfig so it fails builds. Pair with a code-fix provider for one-click migration.
Q9. Why are static lambdas important in incremental generator pipelines? Captures of mutable state defeat caching: the lambda's identity changes per-instance, so the stage can't be cached. static lambdas can't capture, eliminating this hazard.
Q10. What is OutputItemType="Analyzer" doing in the project reference? Tells the consuming project to treat the referenced project's output as an analyzer (which includes generators) — loaded into the compiler, not added as a runtime reference. Combined with ReferenceOutputAssembly="false" for cleanliness.
Q11. What's the difference between INamedTypeSymbol and a typed DTO in the pipeline? INamedTypeSymbol is per-compilation — equality is reference-based across compilations, which means caching can't work. A DTO with value equality (record + immutable members) caches correctly.
Q12. Why might an analyzer be slow in the IDE even though it's fast on the command line? The IDE invokes analyzers on every change. If the analyzer registers per-syntax-node and does expensive work each time, it dominates IDE responsiveness. The fix: RegisterCompilationStartAction to do setup once, then per-node actions reading the precomputed data.
Gotchas / common mistakes
- ⚠️ Targeting
net10.0in a generator project — may fail to load in some toolchains. Stick tonetstandard2.0unless you have a specific reason. - ⚠️ Capturing context in lambdas — defeats caching, slows IDE.
- ⚠️ Allocating in inner pipeline transforms — every call allocates; multiplied across edits.
- ⚠️ Outputting non-equatable types from pipeline stages — caching breaks silently; you only notice the IDE getting sluggish.
- ⚠️ Writing an analyzer that reports on generated code — set
ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None)to ignore generated files. - ⚠️ Forgetting
EnforceExtendedAnalyzerRules=true— without it, your generator can pull in references it shouldn't (e.g.,System.Text.Json) that won't be available at host-time. - ⚠️ Generating extension methods on
stringor other very common types — names collide silently across generators.
Further reading
- Source generators overview
- Incremental generators design notes
- Source generator cookbook
[LoggerMessage]- System.Text.Json source generation
- Regex source generators
- Andrew Lock's series on source generators