C# & .NET (C# 14, .NET 10 LTS) Module 18 – Reflection, attributes and source generators
Using source generators in .NET
Use the source generators that ship with .NET for JSON, regex, logging, options and P/Invoke, read the code they write, and fix the warnings they report.
What you will learn
- Use the .NET source generators for JSON, regular expressions, logging, options validation and native calls
- Inspect the files a generator writes with EmitCompilerGeneratedFiles, and read what they do
- Explain why generators need partial members, and declare partial methods, properties, constructors and events
- Compare reflection-based and generated code for trim and AOT warnings and for allocations
- Diagnose the SYSLIB errors and warnings that generators report, and fix their cause
Before you start
What you’ll learn · Before you start
What you will learn
- Use the .NET source generators for JSON, regular expressions, logging, options validation and native calls
- Inspect the files a generator writes with EmitCompilerGeneratedFiles, and read what they do
- Explain why generators need partial members, and declare partial methods, properties, constructors and events
- Compare reflection-based and generated code for trim and AOT warnings and for allocations
- Diagnose the SYSLIB errors and warnings that generators report, and fix their cause
Before you start
On this page
The same JSON and configuration code built with 0 warnings as ordinary code and with 6 once the trim and AOT analyzers looked at it, each one a call that a Native AOT build could break. Generating that code while the program is built removed all six, and the [LoggerMessage] generator took a log call from 64 bytes to 0.
You will see what a source generator is, use the generators that ship with .NET, read the files they write and learn why each one needs a partial member from you. Under every program is the output it gave on .NET 10.
A source generator writes code into your build, next to yours
A source generator: A component that runs inside the C# compiler and adds code to the program while it is built. The regex source generator turns each [GeneratedRegex] pattern into C# matching code. More in “Regular expressions with source generation” runs inside the C# compiler while your project builds. Roslyn’s design notes give it two powers and one limit: it reads the compilation, the compiler’s whole model of your code, and it adds new source files to it, but it may not modify code you wrote. .NET ships several:
| You write | Generator | What it writes |
|---|---|---|
[JsonSerializable] on a context class |
System.Text.Json | the metadata that serialisation needs |
[GeneratedRegex] on a partial property |
regular expressions | the matching code for the pattern |
[LoggerMessage] on a partial method |
logging | the method that checks the level, then logs |
[OptionsValidator] on a partial class |
options | a validator for the rules on the options |
[LibraryImport] on a partial method |
interop | the marshalling around a native call |
| one property in the project file | configuration binding | the binding code behind each Get<T> and Bind call |
The smallest is the regex generator. regex.cs checks four IFSC codes with a generated pattern and with the same
pattern built while the program runs:
// [GeneratedRegex] on a partial property: the regex source generator writes the matching code while the program is built.
using System.Text.RegularExpressions;
string[] codes = ["ABCD0001234", "ABCD1001234", "abcd0001234", "ABCD000123"];
Regex atRunTime = new(@"^[A-Z]{4}0[A-Z0-9]{6}$");
foreach (string code in codes)
Console.WriteLine($"{code,-12} generated: {Codes.Ifsc.IsMatch(code),-5} built at run time: {atRunTime.IsMatch(code)}");
Console.WriteLine($"type of the generated regex: derived from Regex: {Codes.Ifsc.GetType().IsSubclassOf(typeof(Regex))}");
Console.WriteLine($"type of the run-time regex: {atRunTime.GetType().Name}");
Console.WriteLine($"the same object on every read: {ReferenceEquals(Codes.Ifsc, Codes.Ifsc)}");
static partial class Codes
{
// IFSC: four letters for the bank, then 0, then six letters or digits for the branch.
[GeneratedRegex(@"^[A-Z]{4}0[A-Z0-9]{6}$")]
public static partial Regex Ifsc { get; }
} Output
ABCD0001234 generated: True built at run time: True ABCD1001234 generated: False built at run time: False abcd0001234 generated: False built at run time: False ABCD000123 generated: False built at run time: False type of the generated regex: derived from Regex: True type of the run-time regex: Regex the same object on every read: True
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run regex.cs
Both gave the same answers. The generated one is a class derived from Regex that the generator wrote during the
build, and the property hands out the same object on every read, while the run-time Regex parsed its pattern when
the program ran. Step through a build in which four generators work at once:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Your half. Program.cs declares BookingJson, Patterns.Pnr, Log.Booked and BookingLimitsValidator as partial members with attributes, and supplies no bodies for them.
- The model. The compiler parses the files and works out every type and member: the same model it uses to report errors and that your editor uses for completion.
- Generators read it. Each generator looks for its own attribute in that model: [JsonSerializable], [GeneratedRegex], [LoggerMessage] and [OptionsValidator]. It can read everything, but change nothing you wrote.
- They add files. They write the other halves as new source files: nine from these four, from the JSON context with one file per type to the single logging method.
- One compilation. The compiler compiles your files and the generated ones together into one assembly. At run time nothing distinguishes the generated code: no reflection, no files read.
- When your half is wrong. If a declaration does not fit, for example a context that is not partial, the generator writes nothing for it and reports a SYSLIB diagnostic instead, which the build shows like a compiler error.
Partial members are the contract between your code and a generator
A generator cannot change your code, so you declare the member and it supplies the body. That is the job of
partial member: A method, property, indexer, constructor or event declared in one part of a partial type and implemented in another, often by a source generator. Only a private void method may be left without an implementation.: a declaring part without a body and an implementing part with one.
partial.cs writes both halves by hand, as you and a generator would:
// The two halves of partial members, as your code and a generator share one type. Here both halves are in one file;
// a generator would write the second.
var ticket = new Ticket("4521873690");
ticket.Changed += what => Console.WriteLine($"event: {what} changed");
ticket.Passengers = 3;
Console.WriteLine(ticket.Describe());
ticket.Save();
Console.WriteLine($"after Save(): Saved = {Ticket.Saved}, since the call to OnSaved, ++Saved included, was removed");
// Your half: declarations only.
partial class Ticket
{
public partial Ticket(string pnr);
public partial int Passengers { get; set; }
public partial string Describe();
public partial event Action<string>? Changed;
partial void OnSaved(int count);
public static int Saved;
public void Save() => OnSaved(++Saved);
}
// The other half: the implementations, as a generator would write them. OnSaved gets none.
partial class Ticket
{
private readonly string _pnr;
private int _passengers;
private Action<string>? _changed;
public partial Ticket(string pnr) => _pnr = pnr;
public partial int Passengers
{
get => _passengers;
set
{
_passengers = value;
_changed?.Invoke(nameof(Passengers));
}
}
public partial string Describe() => $"{_pnr}: {_passengers} passengers";
public partial event Action<string>? Changed
{
add => _changed += value;
remove => _changed -= value;
}
} Output
event: Passengers changed 4521873690: 3 passengers after Save(): Saved = 0, since the call to OnSaved, ++Saved included, was removed
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run partial.cs
The constructor, the property, the method and the event each worked through their implementing part. OnSaved had
none: a void partial method without an access modifier, which leaves it private, and without out parameters
may be left unimplemented, and Microsoft’s reference says the compiler then removes it and its calls. The run proves
the calls went with their arguments, since ++Saved never ran.
Each release let more kinds of member be partial, and each one gave generators a new place to fill in:
- C# 2
Partial types
A class, struct or interface can be split over several files, so a tool can own one file of a type while you own another.
- C# 3
Partial methods
A private void method may be declared in one part and implemented in another, or nowhere: then the compiler removes it and every call to it.
- C# 9
Partial methods for generators generators
Partial methods may be public, return values and take out parameters, and such a method must be implemented, which is the contract a generator fills.
- C# 13
Partial properties and indexers generators
A property can be declared partial too, which is how [GeneratedRegex] works on a property instead of a method.
- C# 14
Partial constructors and events generators
Instance constructors and events join the list; the implementing declaration of an event must write its add and remove accessors.
The timeline has 5 events, from C# 2 to C# 14.
Checkpoint
Check: What a source generator does · Partial members as the contract
You can read every line a generator writes
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> keeps a copy of each generated file under obj/. The
project generated uses four generators of .NET and sets it:
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
var booking = new Booking("4521873690", 2);
Console.WriteLine(JsonSerializer.Serialize(booking, BookingJson.Default.Booking));
Console.WriteLine($"PNR well formed: {Patterns.Pnr.IsMatch(booking.Pnr)}");
using var factory = LoggerFactory.Create(builder => builder.AddProvider(new PrintProvider()));
Log.Booked(factory.CreateLogger("Bookings"), booking.Pnr, booking.Passengers);
ValidateOptionsResult result = new BookingLimitsValidator().Validate(null, new BookingLimits { MaxPassengers = 9 });
Console.WriteLine($"options valid: {result.Succeeded}; {result.FailureMessage}");
record Booking(string Pnr, int Passengers);
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(Booking))]
partial class BookingJson : JsonSerializerContext;
static partial class Patterns
{
[GeneratedRegex(@"^\d{10}$")]
public static partial Regex Pnr { get; }
}
static partial class Log
{
[LoggerMessage(EventId = 1, Level = LogLevel.Information, Message = "Booked {Pnr} for {Passengers} passengers")]
public static partial void Booked(ILogger logger, string pnr, int passengers);
}
class BookingLimits
{
[Range(1, 6)]
public int MaxPassengers { get; set; }
}
[OptionsValidator]
partial class BookingLimitsValidator : IValidateOptions<BookingLimits>;
// A logger that prints each event with its structured values, so the output shows what the generated method passes.
sealed class PrintProvider : ILoggerProvider
{
public ILogger CreateLogger(string category) => new PrintLogger(category);
public void Dispose() { }
sealed class PrintLogger(string category) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel level) => true;
public void Log<TState>(LogLevel level, EventId id, TState state, Exception? error, Func<TState, Exception?, string> formatter)
{
Console.WriteLine($"{level} {category}[{id.Id}]: {formatter(state, error)}");
if (state is IReadOnlyList<KeyValuePair<string, object?>> values)
Console.WriteLine($" values: {string.Join(", ", values.Select(v => $"{v.Key} = {v.Value}"))}");
}
}
} from session import run
# generated uses four generators of .NET: JSON, regular expressions, logging and options validation. Its project sets
# EmitCompilerGeneratedFiles, so the build keeps a copy of every generated file under obj/. The third command lists
# them, the fourth shows the logging method that the generator wrote.
run("""
cd generated
dotnet build -v q > build.log
find obj/Debug/net10.0/generated -type f | sort
cat obj/Debug/net10.0/generated/Microsoft.Extensions.Logging.Generators/Microsoft.Extensions.Logging.Generators.LoggerMessageGenerator/LoggerMessage.g.cs
dotnet run --no-build
""") Output
$ cd generated
$ dotnet build -v q > build.log
$ find obj/Debug/net10.0/generated -type f | sort
obj/Debug/net10.0/generated/Microsoft.AspNetCore.App.SourceGenerators/Microsoft.AspNetCore.SourceGenerators.PublicProgramSourceGenerator/PublicTopLevelProgram.Generated.g.cs
obj/Debug/net10.0/generated/Microsoft.Extensions.Logging.Generators/Microsoft.Extensions.Logging.Generators.LoggerMessageGenerator/LoggerMessage.g.cs
obj/Debug/net10.0/generated/Microsoft.Extensions.Options.SourceGeneration/Microsoft.Extensions.Options.Generators.OptionsValidatorGenerator/Validators.g.cs
obj/Debug/net10.0/generated/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/BookingJson.Booking.g.cs
obj/Debug/net10.0/generated/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/BookingJson.g.cs
obj/Debug/net10.0/generated/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/BookingJson.GetJsonTypeInfo.g.cs
obj/Debug/net10.0/generated/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/BookingJson.Int32.g.cs
obj/Debug/net10.0/generated/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/BookingJson.PropertyNames.g.cs
obj/Debug/net10.0/generated/System.Text.Json.SourceGeneration/System.Text.Json.SourceGeneration.JsonSourceGenerator/BookingJson.String.g.cs
obj/Debug/net10.0/generated/System.Text.RegularExpressions.Generator/System.Text.RegularExpressions.Generator.RegexGenerator/RegexGenerator.g.cs
$ cat obj/Debug/net10.0/generated/Microsoft.Extensions.Logging.Generators/Microsoft.Extensions.Logging.Generators.LoggerMessageGenerator/LoggerMessage.g.cs
// <auto-generated/>
#nullable enable
partial class Log
{
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "10.0.14.42308")]
private static readonly global::System.Action<global::Microsoft.Extensions.Logging.ILogger, global::System.String, global::System.Int32, global::System.Exception?> __BookedCallback =
global::Microsoft.Extensions.Logging.LoggerMessage.Define<global::System.String, global::System.Int32>(global::Microsoft.Extensions.Logging.LogLevel.Information, new global::Microsoft.Extensions.Logging.EventId(1, nameof(Booked)), "Booked {Pnr} for {Passengers} passengers", new global::Microsoft.Extensions.Logging.LogDefineOptions() { SkipEnabledCheck = true });
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Extensions.Logging.Generators", "10.0.14.42308")]
public static partial void Booked(global::Microsoft.Extensions.Logging.ILogger logger, global::System.String pnr, global::System.Int32 passengers)
{
if (logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information))
{
__BookedCallback(logger, pnr, passengers, null);
}
}
}
$ dotnet run --no-build
{"pnr":"4521873690","passengers":2}
PNR well formed: True
Information Bookings[1]: Booked 4521873690 for 2 passengers
values: Pnr = 4521873690, Passengers = 2, {OriginalFormat} = Booked {Pnr} for {Passengers} passengers
options valid: False; MaxPassengers: The field BookingLimits.MaxPassengers must be between 1 and 6.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 generated_session.py
The JSON generator wrote six files, among them one for each type it handles, Booking, Int32 and String; the
regex, logging and options generators one each; and
ASP.NET Core’s shared framework added a tenth, a public partial Program class for top-level programs. The logging
file is short enough to read whole: Booked first asks logger.IsEnabled, and only then calls a delegate built once
by LoggerMessage.Define. The run shows the structured values a logging provider receives, with the template under
{OriginalFormat}.
Key idea
A generator turns an attribute on a partial declaration into ordinary C# in your build: you can read it under obj/, step into it in a debugger, and it runs without reflection.
Checkpoint
Check: Reading the code a generator writes
Generated code against reflection, with the warnings and bytes measured Pro: opens with a Pro, Premium or Ultimate pass
Moving work from run time to build time has two measurable payoffs: trim and AOT warnings that disappear, and allocations that vanish from hot paths. This part measures both.
In this part:
- JSON and configuration: six AOT warnings, then none
- LoggerMessage logs without allocating, measured
4 runnable examples (C#, Python) · about 4 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).
Options validation and LibraryImport, and the code they write Pro: opens with a Pro, Premium or Ultimate pass
Two more generators replace reflection and run-time code with code you can read: the options validator, which reads validation attributes during the build, and LibraryImport, which writes the marshalling of native calls.
In this part:
- The options validator turns attributes into code, once
- LibraryImport writes the marshalling, and asks for unsafe code
3 runnable examples (C#, Python) · about 4 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).
Diagnosing generators, and a lab that moves an app to generated code Pro: opens with a Pro, Premium or Ultimate pass
When a generator cannot do its job it says so with a SYSLIB diagnostic, and the build often shows a second, more confusing error next to it. This part reads the most common pairs and ends with a lab.
In this part:
- Five mistakes, and the error that each one brings with it
- Lab: move an app to generated code
3 runnable examples (C#, Python) · about 4 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).
Practise
Practice that adapts to you
Practise this lesson
10 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does a source generator do, and what can it not do?
It runs inside the compiler during a build, reads the compiler’s model of your code and adds new source files to the compilation. It cannot change or remove code you wrote, which is why generated code fills in partial members.
-
Mid to senior
A profiler shows 64 bytes allocated by every logger.LogInformation call in a hot loop, although Information is switched off in production. Why does a disabled log call allocate, and how do you remove the cost without losing the log line?
-
Fresher to mid
After adding a JsonSerializerContext and a [GeneratedRegex] property, a build fails with CS0534 on the context and CS9248 on the property, among other errors. A teammate wants to go back to reflection and new Regex(...). What do you check first, and how do you fix it?
-
Senior to staff
Your team must publish a console service with Native AOT. It serialises JSON by reflection, binds configuration with Get<T>, builds regexes at run time, logs with LogInformation and calls one native library with DllImport. Plan the move to generated code, and say how you will know it is finished.
Key takeaways
- A source generator runs while the project builds, reads the compilation and adds source files, and it never changes code you wrote.
- Partial members split a member into a declaration and an implementation, which is how your code and a generator share one type; a partial method that may stay unimplemented, and is, disappears with its calls.
- EmitCompilerGeneratedFiles keeps every generated file under obj/, so generated code can be read and debugged like your own.
Terms in this lesson
- partial member
- A method, property, indexer, constructor or event declared in one part of a partial type and implemented in another, often by a source generator. Only a private void method may be left without an implementation.
References
- Source generators (Roslyn design notes) (.NET Foundation (dotnet/roslyn))
- The .NET Compiler Platform SDK (source generators) (Microsoft Learn)
- How to use source generation in System.Text.Json (Microsoft Learn)
- .NET regular expression source generators (Microsoft Learn)
- Compile-time logging source generation (Microsoft Learn)
- Compile-time configuration source generation (Microsoft Learn)
- Compile-time options validation source generation (Microsoft Learn)
- P/Invoke source generation (Microsoft Learn)
- Partial members (Microsoft Learn)
- Diagnostics of the source generators (SYSLIB1xxx) (Microsoft Learn)
- The history of C# (Microsoft Learn)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress