Mid-level 5 min · March 06, 2026

C# Source Generators — Silent Failure After .NET 8 Update

After updating to .

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Source Generators are Roslyn components that produce C# source files at compile time.
  • They run during compilation, not at runtime — zero startup cost, full type safety.
  • The modern approach is IIncrementalGenerator — caches results between builds.
  • Generated code is compiled into your assembly as if you wrote it manually.
  • Biggest mistake: assuming generated code must use string concatenation — use SyntaxGenerator API.
Plain-English First

Imagine you're a chef who has to hand-write the same recipe card every single day before you can start cooking. Now imagine hiring an assistant who watches what ingredients you have, then automatically prints all the recipe cards before you even walk into the kitchen. That assistant is a Source Generator — it runs before your app starts, reads your existing code, and generates new C# files so you never have to write the same boilerplate again. The generated code is real, compiled, fully typed C# — not strings, not reflection magic at runtime.

Every non-trivial C# codebase eventually drowns in boilerplate. You've written the same INotifyPropertyChanged implementation fifteen times. You've hand-rolled JSON serialization methods that drift out of sync with your models. You've copy-pasted mapping code between domain objects and DTOs until maintaining it felt like archaeology. The problem isn't laziness — it's that the language used to give you no good alternative between writing it yourself and paying the runtime cost of reflection or dynamic code generation.

Source Generators, introduced in .NET 5 and significantly improved through .NET 6, 7, and 8 with Incremental Generators, solve this at the right layer: compile time. Instead of generating code while your application runs and slowing down startup, or using T4 templates that live outside the build pipeline and break silently, Source Generators are first-class Roslyn components. They receive a full semantic model of your code, produce new C# source files, and those files are compiled into your assembly as if you wrote them yourself. Zero runtime overhead. Full IntelliSense. Full debuggability.

By the end of this article you'll understand how the Roslyn compilation pipeline hands control to your generator, how to build both a basic ISourceGenerator and the modern IIncrementalGenerator, how to handle real-world scenarios like caching, diagnostics, and multi-targeting, and — critically — the production gotchas that will bite you if you skip them. You'll walk away able to write a production-grade generator and explain it confidently in an interview.

What Are C# Source Generators?

Source Generators are components that plug into the Roslyn compiler pipeline. They receive the full compilation object — syntax trees, semantic model, references — and produce additional source files that become part of the same compilation. Unlike code generation tools that run as a separate build step (T4, custom MSBuild tasks), generators run inside the compiler. That means they have access to type information, can read attributes, and can produce code that the compiler type-checks in the same pass.

The magic happens around the CompilationUnit stage: after the compiler parses all files and binds symbols, it invokes registered generators before emitting IL. The generated files are merged into the compilation as if they were written by hand. No post-processing, no second compilation step.

The key distinction: you should never generate code that could be written as a direct method call or a generic. Generators shine when code must be repeated across types that can't share a common base class, or when the pattern depends on metadata unavailable at the generic level.

io.thecodeforge.generators/HelloWorldGenerator.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using System.Text;

namespace io.thecodeforge.generators
{
    [Generator(LanguageVersion.CSharp12)]
    public sealed class HelloWorldGenerator : IIncrementalGenerator
    {
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            // Register a pipeline that produces a single constant source file
            context.RegisterSourceOutput(context.CompilationProvider,
                (spc, compilation) =>
                {
                    string source = @"// <auto-generated/>
namespace io.thecodeforge
{
    public static class Greeter
    {
        public static string Hello() => ""Hello from a Source Generator!"";
    }
}";
                    spc.AddSource("Greeter.g.cs", SourceText.From(source, Encoding.UTF8));
                });
        }
    }
}
Output
// File: Greeter.g.cs (generated)
namespace io.thecodeforge
{
public static class Greeter
{
public static string Hello() => "Hello from a Source Generator!";
}
}
GeneratorAttribute Language Version
Always specify the [Generator(LanguageVersion)] attribute. Without it, your generator may not activate in newer SDK versions. Use the latest C# version your target frameworks support.
Production Insight
The generator runs in a sandboxed environment — no access to the file system by default. If you need to read external files, you must configure additional files in the project.
The compilation object passed to the generator is a snapshot — don't cache it across builds unless you implement IIncrementalGenerator properly.
Always wrap output in <auto-generated/> to prevent IDE warnings from the generated code.
Key Takeaway
Source Generators run inside the compiler, not before it
Use IIncrementalGenerator for anything beyond a demo
Always add language version to the generator attribute
Choosing Between ISourceGenerator and IIncrementalGenerator
IfGenerator will process static input (no caching needed)
UseUse ISourceGenerator for simplicity, but note it's deprecated in .NET 9+.
IfGenerator reads source attributes or syntax trees and may re-run on partial changes
UseUse IIncrementalGenerator. Required for production-grade performance.
IfGenerator needs to handle .NET Standard 2.0 and older SDKs
UseUse ISourceGenerator with [Generator] attribute targeting CSharp9. Incremental generators require .NET 6+.

Building an Incremental Generator Pipeline

An incremental generator consists of a pipeline of steps. Each step takes an input (like a SyntaxNode or SemanticModel) and produces a value. The framework caches these values using equality comparers you provide. When a source file changes, only the affected pipeline branches re-execute.

The pipeline starts in the Initialize method where you register sources of data: CompilationProvider, SyntaxProvider, etc. The SyntaxProvider filters syntax nodes (e.g., all classes with a specific attribute). Then you transform those nodes into your model (like a DTO description), combine with the compilation, and finally register the source output.

Here's a real example that generates a TypeScript-like partial class for INotifyPropertyChanged without using reflection at runtime.

io.thecodeforge.generators/NotifyPropertyChangedGenerator.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
[Generator(LanguageVersion.CSharp12)]
public sealed class NotifyPropertyChangedGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Step 1: Find all classes with [GenerateNotify] attribute
        IncrementalValuesProvider<ClassDeclarationSyntax> classDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: (node, _) => node is ClassDeclarationSyntax cds &&
                    cds.AttributeLists.Count > 0,
                transform: (ctx, _) => ctx.Node as ClassDeclarationSyntax)
            .Where(c => c is not null)!;

        // Step 2: For each class, extract property info using semantic model
        IncrementalValuesProvider<NotifyModel> models = classDeclarations
            .Select((cds, ct) => ExtractModel(cds, ct));

        // Step 3: Combine and register output
        context.RegisterSourceOutput(models,
            (spc, model) => GeneratePartialClass(spc, model));
    }

    private NotifyModel ExtractModel(ClassDeclarationSyntax cds, CancellationToken ct)
    {
        return new NotifyModel(cds.Identifier.Text);
    }

    private void GeneratePartialClass(SourceProductionContext spc, NotifyModel model)
    {
        string source = $"""
// <auto-generated/>
partial class {model.ClassName} : INotifyPropertyChanged
{{
    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}}
""";
        spc.AddSource($"{model.ClassName}.g.cs", SourceText.From(source, Encoding.UTF8));
    }

    private readonly record struct NotifyModel(string ClassName);
}
Pipeline as Filter Map Reduce
  • SyntaxProvider creates an initial stream of candidate nodes.
  • Select is lazy — it only runs when outputs need to be produced.
  • Each step can be cached by providing a .WithComparer() implementation.
  • The final RegisterSourceOutput is the action that emits generated files.
Production Insight
Without a comparer, every change triggers full pipeline re-execution. For a codebase with 1000 attributes, that means re-extracting model data for all 1000 classes even if only one file changed.
Always implement IEquatable for your model types used in the pipeline. Use record structs or override Equals and GetHashCode.
Don't pass large objects — use SyntaxNode identifiers and semantic model lookups sparingly.
Key Takeaway
Incremental generators eliminate rebuild overhead when only one file changes
Always provide equality comparers for every pipeline step
test your generator's caching behavior with a hot-reload scenario
When to Use SyntaxProvider vs CompilationProvider
IfYou need to generate code based on attributes or method signatures in source
UseUse SyntaxProvider to filter syntax nodes then transform with semantic model.
IfYou need to generate code based on assembly-level metadata or global settings
UseUse CompilationProvider or combine both via .Combine() to gather cross-cutting info.
IfYou need to generate a single file per compilation (like a registry or router)
UseUse CompilationProvider alone. Collect all data in one step and output once.

Real-World Patterns: DTO Generation, Auto-Mapping, and Serialization

The most practical use of source generators is eliminating boilerplate that would otherwise require runtime reflection or code duplication. Three patterns dominate production code:

  1. DTO Generation from database models – Generate DTO classes with the same properties as your EF Core entities, automatically adding JsonPropertyName attributes from column names.
  2. Auto-Mapping – Generate extension methods that map object A to object B based on matching property names and types. No reflection, no slow runtime map building.
  3. Serialization helpers – Generate custom JSON serializers for specific types that avoid the overhead of System.Text.Json's reflection fallback. A source generator can produce highly optimised serialization code that outperforms the built-in serialiser by 2-3x for known types.

The pattern in all three is the same: decorate a type or member with an attribute, then the generator reads that attribute and produces the corresponding code. The generated code is a regular C# file that you can inspect and debug.

io.thecodeforge.generators/DtoGenerator.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Example: [GenerateDto(SourceType = typeof(Entity))]
// Generates: EntityDto with same properties plus [JsonIgnore] for navigation properties

[AttributeUsage(AttributeTargets.Class)]
public sealed class GenerateDtoAttribute : Attribute
{
    public Type SourceType { get; set; }
}

// In generator pipeline:
private IEnumerable<(string Name, ITypeSymbol Type, bool IsNav)> GetProperties(INamedTypeSymbol entity)
{
    foreach (var member in entity.GetMembers())
    {
        if (member is IPropertySymbol prop && !prop.IsStatic)
        {
            yield return (prop.Name, prop.Type,
                prop.Type.TypeKind == TypeKind.Class &&
                prop.Type.SpecialType != SpecialType.System_String);
        }
    }
}
Performance Trap: Over-Generating for Every Type
Generating DTOs for 500 entities will bloat compilation and assembly size. Use a marker interface or filter by namespace to limit scope. The assembly size impact is permanent — every generated class lives in the output DLL.
Production Insight
Source generators produce code that is compiled into the final binary. This means any bug in generated code is a compile-time error in the consuming project — good for safety, but it can hide runtime issues if you trust generation logic too much.
Generated serializers are faster than reflection but slower than hand-written per-type code. Profile before claiming gains.
When generating DTOs, be careful with navigation properties — they can cause circular references if both sides have generated DTOs.
Key Takeaway
Source generators replace reflection at compile time
Limit generation scope to avoid assembly bloat
Always test generated code with both compile and runtime integration tests

Production Pitfalls and How to Avoid Them

Source generators are powerful but they come with unique production traps. Here are the ones that bite even experienced teams:

Pitfall 1: The generator runs in a partial compilation context. You cannot call System.IO.File.ReadAllText inside a generator without first registering the file as an additional file in the csproj. The generator host restricts I/O for security and determinism.

Pitfall 2: Error reporting is silent by default. If your generator throws an unhandled exception, the build may succeed with a warning. You must report errors explicitly via context.ReportDiagnostic(Diagnostic.Create(...)).

Pitfall 3: Multi-targeting confusion. If your library targets multiple frameworks, the generator must be conditionally included only for netstandard2.0 (since it runs on the analyzer host). The generated code must also be compatible with the target framework — you can't use C# 12 syntax in code deployed to .NET Framework 4.6.1.

Pitfall 4: Assembly versioning. Generated code lives in the consuming assembly. If you update the generator package, old generated files are not automatically invalidated. Use incremental generators with versioned pipeline inputs to force regeneration when the generator version changes.

io.thecodeforge.generators/GeneratorProject.csprojMARKUP
1
2
3
4
5
6
7
8
9
10
11
12
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>12</LangVersion>
    <EnforceAnalyzer>true</EnforceAnalyzer>
    <IncludeBuildOutput>false</IncludeBuildOutput>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
  </ItemGroup>
</Project>
Generator Project Must Target netstandard2.0
Source generators run as Roslyn analyzers, which are loaded into a netstandard2.0 host. Target netstandard2.0 even if your consuming project uses .NET 8. Use LangVersion > 11 is fine — your generator can use modern C# syntax.
Production Insight
Generated code that uses features not available in the target framework will cause cryptic compile errors. Always check the [SupportedPlatforms] attribute or generate platform-conditional code.
Source generators cannot reference each other. If you have multiple generators in a single project, they run independently — you cannot make one generator consume output from another.
The build performance impact of a generator that processes syntax trees for every file can be significant. Use predicate (first lambda) in CreateSyntaxProvider to pre-filter quickly without semantic model.
Key Takeaway
Report diagnostics explicitly — exceptions are swallowed
Target netstandard2.0 for the generator project
Profile pipeline predicate to avoid scanning every syntax node
Generator Crashes During Build: Decision Tree
IfGenerator crashes with NullReferenceException
UseCheck that you are not accessing semantic model symbols that may be null for partial types. Use null-conditional operators.
IfGenerator crashes with FileNotFoundException
UseThe generator assembly may be missing at compile time. Ensure it's included as an analyzer via PackageReference with PrivateAssets="all".
IfGenerator runs but produces no output
UseUse EmitCompilerGeneratedFiles=true to see what was generated. May be due to a missing analyzer reference in the consuming project.

When Not to Use Source Generators

Source generators are not the right tool for every problem. They add complexity to the build process and increase assembly size. Avoid them in these scenarios:

  • Simple runtime polymorphism – If a regular interface or abstract class solves the problem, don't bring a generator.
  • Dynamic code that changes during runtime – Generators are compile-time only. Use Reflection.Emit or System.Linq.Expressions.
  • Cross-cutting logging or tracing – Use a runtime AOP framework like Castle DynamicProxy or the .NET interceptors pattern instead of generating wrapper classes for every method.
  • Small projects – The setup overhead of a generator project, package dependency, and build caching complexity is not justified for a single DTO mapping.

A good heuristic: if you can solve the problem with a generic type and a few interfaces, do that first. Only reach for a generator when the pattern repeats across unrelated types and the alternative would be runtime reflection or hand-written copy-paste.

io.thecodeforge/WhenToUse.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Use a source generator if:
// - You need INotifyPropertyChanged on every model class
// - You generate custom JSON serializers for sealed types
// - You auto-register MediatR handlers based on marker interfaces

// Do NOT use a source generator for:
// - Simple mapping between two known types (write a method)
// - Logging around every method call (use DynamicProxy or interceptors)
// - Runtime code generation based on configuration files

public interface ICommand { }
public class CreateOrderCommand : ICommand { }

// This is overkill for a single command.
// A generated handler registration is unnecessary.
Start Without a Generator
Write the boilerplate by hand first. Understand the exact pattern. Then write the generator to automate it. You'll produce better abstraction because you know exactly what the generated code should look like.
Production Insight
A generator that does too much becomes a maintenance nightmare. If you find yourself writing complex code generation logic that reads XML files, multiple attributes, and project properties, you've gone too far.
Generated code is hard to refactor across projects. If the generator is in a NuGet package, all consumers must update to get fixes — you can't just grep and replace generated files.
Key Takeaway
Prefer interfaces and generics before reaching for generators
When in doubt, write the boilerplate once and measure the pain
A generator that saves 2 hours of typing but adds 20 hours of debugging is a net negative
● Production incidentPOST-MORTEMseverity: high

The Generator That Silently Stopped Running After an SDK Update

Symptom
After updating to .NET 8, the source generator produced no output. No compilation errors, no warnings. The generated partial classes were absent, causing runtime binding failures.
Assumption
The generator is forward-compatible and will work across .NET versions without changes. The SDK update shouldn't affect our custom generator.
Root cause
The generator was built as an ISourceGenerator (not IIncrementalGenerator) and relied on the deprecated Microsoft.CodeAnalysis.Common package version 3.x. .NET 8 ships with Roslyn 4.8, which dropped support for generators that don't declare a supported language version in their [Generator] attribute. The generator didn't specify LanguageVersion.
Fix
Add an explicit [Generator(LanguageVersion.CSharp12)] attribute to the generator class, or better yet, migrate to IIncrementalGenerator which handles versioning automatically through incremental pipeline registration.
Key lesson
  • Always specify the generator attribute's language version target to ensure forward compatibility.
  • Migrate to IIncrementalGenerator for production generators — it's not optional anymore.
  • Add a unit test that compiles the generator and verifies output exists after each SDK version bump.
Production debug guideSymptom → Action reference for when your generator doesn't behave4 entries
Symptom · 01
Generator produces no output during build
Fix
Add a breakpoint in the Initialize method using Debugger.Launch(). Enable source generator debugging via <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> in csproj.
Symptom · 02
Generated code causes compilation errors in consuming project
Fix
Inspect the emitted files in the IntermediateOutputPath (obj/) folder. Look for syntax errors. Use Roslyn's SourceGenerator.Run command from the command line to isolate.
Symptom · 03
Generator runs on every build despite incremental caching
Fix
Check that your IIncrementalGenerator's pipeline uses .WithComparer() on each step. Missing comparers cause full regeneration on every build.
Symptom · 04
Generator crashes but build succeeds silently
Fix
Wrap all pipeline operations in try/catch and call context.ReportDiagnostic() to surface errors. The generator exception is swallowed by the host.
★ Source Generator Quick Debug Cheat SheetThree commands and one config change to debug any source generator issue fast.
No generated code appears in IntelliSense
Immediate action
Clean and rebuild the project with detailed build logging.
Commands
dotnet clean && dotnet build -v n -f net8.0
Check IntermediateOutputPath for generated files: ls obj/Debug/net8.0/generated/
Fix now
Add <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> to the csproj and check the generated folder.
Generator throws NullReferenceException+
Immediate action
Enable source generator debugging in Visual Studio.
Commands
Set environment variable ROSLYN_GENERATOR_DEBUG to 1 and restart the build.
Attach debugger to the dotnet.exe process that runs the build.
Fix now
Use Debugger.Launch() in the generator's Initialize method and rebuild from command line.
Generator output changes unexpectedly between builds+
Immediate action
Check for non-deterministic inputs like file order or hash codes.
Commands
dotnet build --force (to ignore incremental cache)
Compare generated files with diff tool against previous known-good version.
Fix now
Implement IEquatable<InputType> in your generator pipeline inputs to ensure stable hashing.
Source Generator vs Alternative Code Generation Techniques
TechniqueRuntime OverheadType SafetyBuild Time ImpactDebugging ExperienceMaintenance Complexity
Source GeneratorZero (compile-time only)Full (generated code is compiled)Moderate (adds to compilation time)Can debug generated code if EmitCompilerGeneratedFiles=trueLow to medium (generator is a separate project)
Reflection (e.g., PropertyChanged.Fody)Low to moderate (MethodBase.GetCurrentMethod, etc.)Partial (runtime errors if types mismatch)MinimalHard (runtime weaving tool)Medium (tool configuration)
T4 TemplatesNone (generated at design time)Full if regenerated before buildMinimal (manual or before-build)Good (generated file is in source)Low to medium (template syntax)
Runtime Code Generation (Reflection.Emit)High (compilation on first use)None (runtime exception if IL is wrong)None (runtime only)Very hard (cannot debug emitted IL)High (IL is fragile)

Key takeaways

1
Source generators run inside the Roslyn compiler pipeline, producing C# source files that are compiled alongside your code.
2
Always use IIncrementalGenerator for production work
it caches intermediate results and avoids full re-runs.
3
Debug by enabling EmitCompilerGeneratedFiles=true and using Debugger.Launch()
never assume the generator ran correctly.
4
Limit generation scope with predicate filtering to avoid scanning every syntax node; profile build times.
5
A generator that saves a few keystrokes but increases build time by 10 seconds is a net loss
measure before committing.
6
Generated code is real C#
test it with unit tests that compile and validate output, not just integration tests.

Common mistakes to avoid

4 patterns
×

Using ISourceGenerator instead of IIncrementalGenerator

Symptom
Full re-execution of generator on every build, even when nothing changed. Can add 30+ seconds to build time for large codebases.
Fix
Migrate to IIncrementalGenerator. Implement a pipeline with proper comparers. The incremental framework caches intermediate results.
×

Omitting [Generator(LanguageVersion)] attribute

Symptom
Generator silently stops running after SDK upgrade. No errors, but no generated code either.
Fix
Add [Generator(LanguageVersion.CSharp12)] (or your target C# version) to the generator class declaration.
×

Accessing the file system inside the generator without registering additional files

Symptom
IOException or SecurityException at compile time. The generator host blocks I/O.
Fix
In the csproj, add <AdditionalFiles Include="config.json" /> and read via context.AdditionalTextsProvider.
×

Not handling incremental caching correctly (missing comparer)

Symptom
Build times increase linearly with codebase size, even though only one file changed.
Fix
Implement IEquatable<T> for your pipeline input types and call .WithComparer(EqualityComparer<T>.Default) on each Select step.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between ISourceGenerator and IIncrementalGenerato...
Q02SENIOR
How do you debug a source generator that is not producing output?
Q03SENIOR
Your team uses a source generator to create INotifyPropertyChanged parti...
Q04SENIOR
What are the limitations of source generators compared to runtime code g...
Q01 of 04SENIOR

Explain the difference between ISourceGenerator and IIncrementalGenerator. When would you use each?

ANSWER
ISourceGenerator is the original interface from .NET 5. It runs once per compilation and has no built-in caching. IIncrementalGenerator (introduced in .NET 6) adds a pipeline that caches intermediate outputs based on input equality. You should use IIncrementalGenerator for any production generator to avoid re-processing unchanged code. ISourceGenerator is acceptable for simple demos or generators that produce the same output regardless of input (e.g., a constant file). However, .NET 9 is deprecating ISourceGenerator, so new code should always use IIncrementalGenerator.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What happens if my source generator throws an exception during build?
02
Can a source generator see other generated code from the same compilation?
03
Do source generators work with Blazor WebAssembly or Xamarin?
04
How do I unit test a source generator?
🔥

That's C# Advanced. Mark it forged?

5 min read · try the examples if you haven't

Previous
Middleware Pipeline in .NET
13 / 15 · C# Advanced
Next
Unsafe Code in C#